Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac384f3d27 | |||
| 92a50f2397 | |||
| ac7ee99a24 | |||
| 379adee35c | |||
| 2ef1a5991c | |||
| 83c638eaac | |||
| cb93114424 | |||
| 04d236ceed | |||
| 5455fed78e | |||
| 6dba0cc498 | |||
| c07ac0db2f | |||
| 20b090e493 | |||
| cb54824f2f | |||
| 3adfb970bf | |||
| 5ecd19db9f | |||
| 299daa2815 | |||
| 7457139849 | |||
| 373cd08b98 | |||
| f52424e05f | |||
| 4a42caef2c | |||
| 7d2618637f | |||
| fbb95a6ee3 | |||
| 40233595b0 | |||
| 403164a6ac | |||
| 1fb2ecc721 | |||
| 27c9b130c7 | |||
| 5fecf7ae9e | |||
| a0d64b12c9 | |||
| d6b897188a | |||
| 39dfbb53d0 | |||
| a39db9c6f6 | |||
| 6ca60d9204 | |||
| b3934992d1 | |||
| 4de7e90d8d | |||
| 917eca4f55 | |||
| 3cf71808c4 | |||
| f266e829cf | |||
| 3df5556223 | |||
| fc29f99190 | |||
| beb586a383 | |||
| 5668d36477 | |||
| 3706b33ee0 | |||
| 85e8ac3b36 | |||
| 91ca75c4dc | |||
| 262ef4fdc2 | |||
| 74e5644646 | |||
| 7de5aa08ff | |||
| 9495e3e903 | |||
| 1e73b76ea6 | |||
| 6697cf3fd8 | |||
| 87aa62d1e2 | |||
| 3136b1ba97 | |||
| 246dea3a58 | |||
| 4b1c7f28a0 | |||
| 9410eda4e3 | |||
| f014686b07 | |||
| 8b68dc0d7f | |||
| 2f4d36d64e | |||
| 6f2ff35656 | |||
| cd00422298 | |||
| 34ee94232f | |||
| 4d91f0cf28 | |||
| bf18cc971f | |||
| 5e1a1ed2e2 | |||
| bd7f7ad98b | |||
| af72cec799 | |||
| 55552c521f | |||
| 2b611a5b14 | |||
| 45a437cbb2 | |||
| 439ec9b76f | |||
| ae3fefb42a | |||
| f1407e41c4 | |||
| c88ca972cf | |||
| 928c37bc02 | |||
| afff74eb2c | |||
| 3a669d528b | |||
| 6789214b31 | |||
| 6636683323 | |||
| 3af9f64265 | |||
| 5a23bdcb69 | |||
| e78726854a | |||
| 472d0f376e | |||
| c8fde60ad4 | |||
| a4b6047e64 | |||
| 6387f955cf | |||
| 5691f36e98 | |||
| 74e7a378bb | |||
| fced9c5a2f | |||
| 20445ca031 | |||
| 6fc9405618 |
@@ -8,6 +8,13 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# node-gyp@latest (invoked via bunx for native install scripts) requires Node >=22;
|
||||
# some runner images ship an older system Node on PATH
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
shell: bash
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
@@ -29,7 +30,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -37,6 +38,7 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -92,7 +94,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -138,9 +140,23 @@
|
||||
"effect",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -176,7 +192,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -203,7 +219,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -225,7 +241,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -249,7 +265,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -269,7 +285,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -321,7 +337,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -363,7 +379,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -417,7 +433,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -431,7 +447,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -443,7 +459,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -475,7 +491,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -491,7 +507,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -522,7 +538,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -541,7 +557,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -553,7 +569,7 @@
|
||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cerebras": "2.0.60",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -578,6 +594,7 @@
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@opencode-ai/codemode": "workspace:*",
|
||||
"@opencode-ai/llm": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -613,7 +630,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.9.3",
|
||||
"gitlab-ai-provider": "6.10.0",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -671,7 +688,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -747,7 +764,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -762,7 +779,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -777,7 +794,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -821,7 +838,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -834,7 +851,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -867,7 +884,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -886,7 +903,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -927,7 +944,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -954,7 +971,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1005,7 +1022,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1923,6 +1940,8 @@
|
||||
|
||||
"@opencode-ai/client": ["@opencode-ai/client@workspace:packages/client"],
|
||||
|
||||
"@opencode-ai/codemode": ["@opencode-ai/codemode@workspace:packages/codemode"],
|
||||
|
||||
"@opencode-ai/console-app": ["@opencode-ai/console-app@workspace:packages/console/app"],
|
||||
|
||||
"@opencode-ai/console-core": ["@opencode-ai/console-core@workspace:packages/console/core"],
|
||||
@@ -3009,7 +3028,7 @@
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
@@ -3581,7 +3600,7 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="],
|
||||
|
||||
@@ -3811,7 +3830,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -4503,7 +4522,7 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
@@ -5353,7 +5372,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
"undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -5555,7 +5574,7 @@
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
@@ -5603,8 +5622,6 @@
|
||||
|
||||
"@actions/github/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"@actions/http-client/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
|
||||
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
|
||||
@@ -5691,6 +5708,8 @@
|
||||
|
||||
"@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="],
|
||||
|
||||
"@astrojs/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@astrojs/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@astrojs/sitemap/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
@@ -5855,6 +5874,8 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node/undici": ["undici@8.3.0", "", {}, "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -5865,12 +5886,16 @@
|
||||
|
||||
"@electron/fuses/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"@electron/get/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"@electron/notarize/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@electron/osx-sign/isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
|
||||
|
||||
"@electron/rebuild/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
|
||||
"@electron/universal/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"@electron/universal/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -5901,6 +5926,8 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
@@ -5919,6 +5946,8 @@
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
|
||||
|
||||
"@npmcli/run-script/node-gyp": ["node-gyp@12.3.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-QNcUWM+HgJplcPzBvFBZ9VXacyGZ4+VTOb80PwWR+TlVzoHbRKULNEzpRsnaoxG3Wzr7Qh7BYxGDU3CbKib2Yg=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.10", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
@@ -5983,6 +6012,8 @@
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"@opencode-ai/core/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||
|
||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
@@ -5993,6 +6024,8 @@
|
||||
|
||||
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||
|
||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
|
||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||
@@ -6147,6 +6180,8 @@
|
||||
|
||||
"astro/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
|
||||
|
||||
"astro/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"astro/common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="],
|
||||
|
||||
"astro/diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="],
|
||||
@@ -6187,6 +6222,8 @@
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.1.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"crc/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
@@ -6237,6 +6274,8 @@
|
||||
|
||||
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
@@ -6301,6 +6340,8 @@
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"miniflare/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
@@ -6321,9 +6362,7 @@
|
||||
|
||||
"nitro/undici": ["undici@7.26.0", "", {}, "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg=="],
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="],
|
||||
"node-gyp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"node-gyp-build-optional-packages/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
@@ -6333,6 +6372,8 @@
|
||||
|
||||
"nypm/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||
|
||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||
|
||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
@@ -6341,6 +6382,8 @@
|
||||
|
||||
"opencode/minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="],
|
||||
|
||||
"opencode/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
@@ -6423,7 +6466,7 @@
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
"terser/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
@@ -6443,6 +6486,8 @@
|
||||
|
||||
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"unplugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"unused-filename/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
@@ -6457,6 +6502,8 @@
|
||||
|
||||
"verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||
|
||||
"vite-plugin-dynamic-import/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
|
||||
"vitest/@vitest/expect": ["@vitest/expect@4.1.7", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w=="],
|
||||
@@ -6875,6 +6922,8 @@
|
||||
|
||||
"@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"@storybook/csf-plugin/unplugin/webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
@@ -6903,8 +6952,6 @@
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -7035,12 +7082,24 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"motion/framer-motion/motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.54", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OyXt0zK8y2/ZIyWlbxTv2r1M7AK227S+Gl4BYOEF42q0wz1n5m4fwR8L4Fy/MQ4Ho6xje47MPsFcRdIqIyP6Rw=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||
|
||||
"openid-client/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
@@ -7309,6 +7368,8 @@
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"archiver-utils/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||
|
||||
"archiver-utils/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||
@@ -7331,8 +7392,6 @@
|
||||
|
||||
"editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -7373,6 +7432,8 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||
|
||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||
@@ -7439,6 +7500,8 @@
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron-builder-squirrel-windows/app-builder-lib/hosted-git-info/lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"electron-builder/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"electron-builder/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-An7vZmA+sVlxoSOMK4vQvkq3KJOF6Rm9IBU62LN3Hkg=",
|
||||
"aarch64-linux": "sha256-Hx4trrBLxRDyY4HQc96EQZUa8abIeetNvE8GtRFvrrc=",
|
||||
"aarch64-darwin": "sha256-/eXdkybUkDCrfQRCkZHsgCa3BM/OfaQzH/z7OxwlpVM=",
|
||||
"x86_64-darwin": "sha256-flhsuFXikpI8tzK7nTwIVF/jzpmb5p+BWxGa/5vhN54="
|
||||
"x86_64-linux": "sha256-ZmAIcG3wPgssKejLe/EMJOjPuMHCyRiiItRvSqIjaME=",
|
||||
"aarch64-linux": "sha256-lbAHoVI/CvgdrfKZ/9dKZ/O7DxJvuWEOrDgcCuFGPBk=",
|
||||
"aarch64-darwin": "sha256-Nrs4ijvzQ3J8AYigJSlsr66feUvLZ5iiYCfBdaZNJEc=",
|
||||
"x86_64-darwin": "sha256-ltCaJSO7kZWuMOU49269xvboNVX35OJDFj0gXCDf62Y="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"glob": "13.0.5",
|
||||
"husky": "9.1.7",
|
||||
"node-gyp": "12.4.0",
|
||||
"oxlint": "1.60.0",
|
||||
"oxlint-tsgolint": "0.21.0",
|
||||
"prettier": "3.6.2",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Timeline Layout Continuity
|
||||
|
||||
Run from `packages/app`:
|
||||
|
||||
```sh
|
||||
bun run test:stability
|
||||
```
|
||||
|
||||
The suite runs a production build in one Chromium worker. Selected scenarios use deterministic 4x CPU stress after application readiness. This is a stress profile, not emulation of a specific device.
|
||||
|
||||
## What It Proves
|
||||
|
||||
The continuity probe samples DOM-derived layout and visibility state across browser render opportunities. Tests declare explicit contracts such as:
|
||||
|
||||
- Preserve a visible semantic anchor while the user is away from the bottom.
|
||||
- Preserve end anchoring while active content grows or new content appears.
|
||||
- Keep adjacent visible rows ordered without material overlap.
|
||||
- Keep user-selected disclosure state through updates and virtualization.
|
||||
- Avoid a sampled blank interval while one visible surface replaces another.
|
||||
- Preserve logical row and control identity where local state or focus depends on it.
|
||||
- Keep keyboard, wheel, and nested-scroll ownership consistent during remeasurement.
|
||||
|
||||
The suite exercises real browser reducer, projection, component, virtualizer, layout, focus, and interaction code. The backend and event producer are controlled fixtures.
|
||||
|
||||
## What It Does Not Prove
|
||||
|
||||
The pass/fail oracle does not inspect every compositor-presented pixel. A sample taken after `requestAnimationFrame` is a DOM/layout observation, not proof that every sampled state was displayed or that every displayed frame was sampled.
|
||||
|
||||
The suite does not provide complete coverage for:
|
||||
|
||||
- Compositor-only or raster-only glitches.
|
||||
- Color, contrast, canvas, WebGL, masks, irregular clips, or arbitrary occlusion.
|
||||
- Physical display refresh rates, native OS scaling, or a named low-end device.
|
||||
- TCP packetization, proxy buffering, or the complete real server/provider pipeline.
|
||||
|
||||
Playwright video, trace, screenshots, and observation JSON are diagnostic evidence. They are not pixel baselines and do not participate in normal pass/fail decisions.
|
||||
|
||||
For optional before/violation/after screenshots, set `OPENCODE_STABILITY_CAPTURE=1`. Capture is opt-in because compositor readback can perturb timing.
|
||||
|
||||
## Test Layers
|
||||
|
||||
- **Projection:** admitted rows, grouping, labels, and final visible states.
|
||||
- **Local state:** disclosure state, identity, duplicate delivery, and virtualization restoration.
|
||||
- **Interaction:** wheel, keyboard, nested scrolling, actionability, and focus behavior.
|
||||
- **Layout continuity:** anchoring, adjacency, responsive reflow, and visible surface handoffs.
|
||||
- **Reducer hardening:** validly shaped but intentionally reordered, duplicated, removed, or replaced events.
|
||||
- **Oracle contract:** pure analyzer and browser sampler calibration tests.
|
||||
|
||||
Production-lifecycle fixtures should model states emitted by the current producer. Impossible or reordered sequences belong in reducer-hardening tests and must not be described as normal provider behavior.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
Failures retain:
|
||||
|
||||
- `video.webm`
|
||||
- `trace.zip`
|
||||
- failure screenshot
|
||||
- sampled DOM/layout trace JSON
|
||||
- event markers and summarized violations
|
||||
|
||||
The analyzer records both unclipped layout bounds and ancestor-clipped visible intersections. Scrollbar and raw `scrollTop` changes alone do not fail continuity checks; user-visible semantic anchor movement does.
|
||||
@@ -0,0 +1,250 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline adverse visual stability", () => {
|
||||
test("does not pull a scrolled-away user while an active shell grows", async ({ page }, testInfo) => {
|
||||
const activeShellID = "prt_adverse_01_shell"
|
||||
const messages = [
|
||||
...history(24),
|
||||
userMessage(),
|
||||
assistantMessage([shell(activeShellID, "running")], { completed: false }),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages,
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 30,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -450 }))
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 450)
|
||||
})
|
||||
await page.waitForTimeout(150)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(100)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
await waitForVisualSettle(page, [`[data-timeline-key="${anchor}"]`])
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(1))), 180)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(10))), 90)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "running", lines(50))), 350)
|
||||
await timeline.send(partUpdated(shell(activeShellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"scrolled-away-shell",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves an explicit shell state across virtualization", async ({ page }) => {
|
||||
const targetID = "prt_virtual_shell"
|
||||
const messages = [
|
||||
userMessage(undefined, { id: "msg_0000_virtual_user", created: 1700000000000 }),
|
||||
assistantMessage([shell(targetID, "completed", lines(20))], {
|
||||
id: "msg_0001_virtual_assistant",
|
||||
parentID: "msg_0000_virtual_user",
|
||||
created: 1700000001000,
|
||||
}),
|
||||
...history(35, 10),
|
||||
]
|
||||
await setupTimeline(page, { messages, settings: { shellToolPartsExpanded: false } })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => {
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1_000 }))
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("keeps narrow viewport rows ordered during long shell growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_narrow_01_shell"
|
||||
const followingID = "prt_narrow_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[shell(shellID, "running"), textPart(followingID, "A narrow following row that wraps across lines.")],
|
||||
{
|
||||
completed: false,
|
||||
},
|
||||
),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 430, height: 800 },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(10))), 100)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", wideLines(50))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", wideLines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"narrow-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps visible rows ordered while resizing desktop to narrow and back", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_resize_01_shell"
|
||||
const contextIDs = ["prt_resize_02_read", "prt_resize_03_glob"]
|
||||
const followingID = "prt_resize_04_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
shell(shellID, "completed", wideLines(15)),
|
||||
toolPart(contextIDs[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(contextIDs[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
textPart(followingID, "Following responsive timeline content that wraps on narrow screens."),
|
||||
]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.setViewportSize({ width: 430, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 900, height: 800 })
|
||||
await page.waitForTimeout(500)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"responsive-resize",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "context", "following"] },
|
||||
{ type: "unique", regions: ["shell", "context", "following"] },
|
||||
{ type: "stable", regions: ["shell", "context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 4, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "context", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function history(count: number, offset = 0): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = index + offset
|
||||
const prefix = `msg_0${String(value).padStart(3, "0")}_history`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1699990000000 + value * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(
|
||||
`prt_history_${String(value).padStart(3, "0")}`,
|
||||
`Historical response ${value}. ${"Stable history content. ".repeat(8)}`,
|
||||
),
|
||||
],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1699990001000 + value * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function wideLines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1} ${"wide-output-".repeat(20)}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
event,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
||||
list: { path: "src" },
|
||||
}
|
||||
|
||||
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_append_01_read"
|
||||
const followingID = "prt_append_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
||||
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
||||
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-append",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
||||
const textID = "prt_split_02_text"
|
||||
const followingID = "prt_split_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
||||
textPart(textID, "Boundary"),
|
||||
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
||||
textPart(followingID, "Following split groups"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-split-merge",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["following"] },
|
||||
{ type: "unique", regions: ["following"] },
|
||||
{ type: "stable", regions: ["following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("removing the first context member replaces the group once without overlapping following content", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
||||
const followingID = "prt_key_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", inputs.read),
|
||||
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
||||
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
||||
textPart(followingID, "Following replaced group"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const originalRowKey = await original.evaluate((element) =>
|
||||
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
||||
)
|
||||
await original.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-first-remove",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
||||
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
||||
).toBe(originalRowKey)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
@@ -0,0 +1,113 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
// Fractional scaling exercises different browser rounding than the baseline.
|
||||
for (const deviceScaleFactor of [1, 1.25]) {
|
||||
test(`keeps shell growth ordered at device scale ${deviceScaleFactor}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_01_shell`
|
||||
const followingID = `prt_dpr_${String(deviceScaleFactor).replace(".", "_")}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following scaled shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
deviceScaleFactor,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 180)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `dpr-${deviceScaleFactor}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
for (const reducedMotion of [true]) {
|
||||
test(`keeps shell and status transitions ordered with reduced motion ${reducedMotion}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const shellID = `prt_motion_${reducedMotion}_01_shell`
|
||||
const followingID = `prt_motion_${reducedMotion}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following motion profile")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
reducedMotion,
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(10))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, `reduced-motion-${reducedMotion}`, trace, shellPlan(regions))
|
||||
})
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellPlan<Regions extends ReturnType<typeof defineVisualRegions>>(
|
||||
regions: Regions & Record<"shell" | "following", { selector: string }>,
|
||||
) {
|
||||
return visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "edit", tool: "edit", input: { filePath: "src/edit.ts" } },
|
||||
{
|
||||
name: "multi patch",
|
||||
tool: "apply_patch",
|
||||
input: { files: ["src/a.ts", "src/b.ts", "src/old.ts", "src/moved.ts"] },
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`stabilizes ${profile.name} pending to completed`, async ({ page }, testInfo) => {
|
||||
const partID = `prt_file_matrix_${profiles.indexOf(profile)}`
|
||||
const followingID = `prt_file_matrix_following_${profiles.indexOf(profile)}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(partID, profile.tool, "pending", profile.input),
|
||||
textPart(followingID, `Following ${profile.name}`),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart(partID, profile.tool, "running", profile.input)), 180)
|
||||
await timeline.send(partUpdated(completedPart(partID, profile)), 900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`file-${profile.name}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function completedPart(partID: string, profile: (typeof profiles)[number]) {
|
||||
if (profile.tool === "edit") {
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, {
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 50,
|
||||
deletions: 50,
|
||||
before: source(50, false),
|
||||
after: source(50, true),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
const files = [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
]
|
||||
return toolPart(partID, profile.tool, "completed", profile.input, { metadata: { files } })
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 20,
|
||||
deletions: type === "add" ? 0 : 20,
|
||||
before: type === "add" ? undefined : source(20, false),
|
||||
after: type === "delete" ? undefined : source(20, true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds patch files incrementally without resetting outer expansion", async ({ page }, testInfo) => {
|
||||
const patchID = "prt_incremental_01_patch"
|
||||
const followingID = "prt_incremental_02_following"
|
||||
const first = patchFile("src/a.ts", "update")
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(patchID, "apply_patch", "running", { files: [first.filePath] }, { metadata: { files: [first] } }),
|
||||
textPart(followingID, "Following incremental patch"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
const third = patchFile("src/old.ts", "delete")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"running",
|
||||
{ files: [first.filePath, second.filePath] },
|
||||
{ metadata: { files: [first, second] } },
|
||||
),
|
||||
),
|
||||
240,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: [first.filePath, second.filePath, third.filePath] },
|
||||
{ metadata: { files: [first, second, third] } },
|
||||
),
|
||||
),
|
||||
800,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"incremental-patch",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["patch", "following"] },
|
||||
{ type: "unique", regions: ["patch", "following"] },
|
||||
{ type: "stable", regions: ["patch", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["patch", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator('[data-scope="apply-patch"] [data-type="delete"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
assistantMessage,
|
||||
event,
|
||||
toolPart,
|
||||
userMessage,
|
||||
validateTimelineEvent,
|
||||
validateTimelineMessages,
|
||||
type PartSeed,
|
||||
} from "./fixture"
|
||||
|
||||
describe("timeline fixture validation", () => {
|
||||
test("accepts a valid timeline", () => {
|
||||
expect(validateTimelineMessages([userMessage(), assistantMessage()])).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("rejects malformed SDK values at runtime", () => {
|
||||
expect(() =>
|
||||
assistantMessage([], {
|
||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineEvent({
|
||||
directory: "C:/OpenCode/TimelineStability",
|
||||
payload: {
|
||||
id: "evt_invalid_status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
},
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate IDs and orphan assistants", () => {
|
||||
expect(() => validateTimelineMessages([userMessage(), userMessage()])).toThrow(/duplicate message ID/)
|
||||
expect(() =>
|
||||
validateTimelineMessages([userMessage(), assistantMessage([], { parentID: "msg_missing_parent" })]),
|
||||
).toThrow(/parent user/)
|
||||
})
|
||||
|
||||
test("assigns deterministic event IDs", () => {
|
||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
if (false) {
|
||||
const userSeed = { id: "prt_type_user", type: "text", text: "typed" } satisfies PartSeed<"user">
|
||||
userMessage([userSeed])
|
||||
|
||||
// @ts-expect-error Tool completion fields are not valid while pending.
|
||||
toolPart("prt_invalid_pending", "bash", "pending", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool completion fields are not valid while running.
|
||||
toolPart("prt_invalid_running", "bash", "running", {}, { output: "impossible" })
|
||||
// @ts-expect-error Tool error fields are not valid after completion.
|
||||
toolPart("prt_invalid_completed", "bash", "completed", {}, { error: "impossible" })
|
||||
|
||||
assistantMessage([
|
||||
// @ts-expect-error Agent references belong to user messages, not assistant messages.
|
||||
{ id: "prt_invalid_owner", type: "agent", name: "explore", source: { value: "@explore", start: 0, end: 8 } },
|
||||
])
|
||||
|
||||
// @ts-expect-error Retry status events require message and next.
|
||||
event("session.status", { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } })
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type {
|
||||
AssistantMessage,
|
||||
GlobalEvent,
|
||||
Message,
|
||||
Part,
|
||||
Session,
|
||||
SessionStatus,
|
||||
ToolPart,
|
||||
ToolState,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { installSseTransport } from "../../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
|
||||
export const directory = "C:/OpenCode/TimelineStability"
|
||||
export const projectID = "proj_timeline_stability"
|
||||
export const sessionID = "ses_timeline_stability"
|
||||
export const userID = "msg_1000_timeline_user"
|
||||
export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type TimelinePayload = Extract<
|
||||
GlobalEvent["payload"],
|
||||
{
|
||||
type:
|
||||
| "message.updated"
|
||||
| "message.removed"
|
||||
| "message.part.updated"
|
||||
| "message.part.removed"
|
||||
| "message.part.delta"
|
||||
| "session.status"
|
||||
}
|
||||
>
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
||||
export type EventPayload = TimelineEvent
|
||||
export type ToolStatus = ToolState["status"]
|
||||
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
||||
|
||||
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
||||
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
||||
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
||||
export type PartSeed<Owner extends Message["role"]> =
|
||||
OwnedPart<Owner> extends infer Candidate
|
||||
? Candidate extends Part
|
||||
? Omit<Candidate, "sessionID" | "messageID">
|
||||
: never
|
||||
: never
|
||||
|
||||
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
: State extends "running"
|
||||
? { title?: string; metadata?: Record<string, unknown>; output?: never; error?: never }
|
||||
: State extends "error"
|
||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
||||
const timelineEventSchema = Schema.Union([
|
||||
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
||||
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
||||
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
||||
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
||||
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
||||
eventSchema("session.status", SessionStatusEvent.Status.data),
|
||||
])
|
||||
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
||||
let eventSequence = 0
|
||||
|
||||
export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
cpuRate?: number
|
||||
viewport?: { width: number; height: number }
|
||||
eventRetry?: number
|
||||
reducedMotion?: boolean
|
||||
locale?: string
|
||||
deviceScaleFactor?: number
|
||||
seedHistory?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages = validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) => message.info.role === "assistant")
|
||||
const initialStatus = decodeStatus(
|
||||
active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
})
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: false,
|
||||
shellToolPartsExpanded: false,
|
||||
showReasoningSummaries: false,
|
||||
showSessionProgressBar: true,
|
||||
...settings,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, input.settings ?? {})
|
||||
if (input.locale) {
|
||||
await page.addInitScript((locale) => {
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale }))
|
||||
}, input.locale)
|
||||
}
|
||||
if (input.reducedMotion) await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize(input.viewport ?? { width: 1400, height: 900 })
|
||||
if (input.deviceScaleFactor) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
const viewport = input.viewport ?? { width: 1400, height: 900 }
|
||||
await devtools.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: input.deviceScaleFactor,
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
if (input.cpuRate && input.cpuRate > 1) {
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: input.cpuRate })
|
||||
}
|
||||
|
||||
return {
|
||||
transport,
|
||||
async send(event: TimelineEvent, delay = 0) {
|
||||
const valid = validateTimelineEvent(event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
if (delay) await page.waitForTimeout(delay)
|
||||
},
|
||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||
for (const item of sequence) {
|
||||
const valid = validateTimelineEvent(item.event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
await page.waitForTimeout(item.delay)
|
||||
}
|
||||
},
|
||||
async settle(frames = 3) {
|
||||
await page.evaluate(
|
||||
(frames) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let remaining = frames
|
||||
const tick = () => {
|
||||
remaining--
|
||||
if (remaining <= 0) return resolve()
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}),
|
||||
frames,
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvent(event: EventPayload) {
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return [
|
||||
event.payload.type,
|
||||
part.id,
|
||||
part.type === "tool" ? part.tool : part.type,
|
||||
part.type === "tool" ? part.state.status : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
}
|
||||
if (event.payload.type === "session.status") {
|
||||
const status = event.payload.properties.status
|
||||
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
}
|
||||
return event.payload.type
|
||||
}
|
||||
|
||||
export function event<const Type extends TimelinePayload["type"]>(
|
||||
type: Type,
|
||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
||||
): TimelineEvent
|
||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
||||
return validateTimelineEvent({
|
||||
directory,
|
||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
||||
})
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions)
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
||||
const messages = [...input]
|
||||
const messageIDs = new Set<string>()
|
||||
const partIDs = new Set<string>()
|
||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (messageIDs.has(message.info.id))
|
||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
||||
messageIDs.add(message.info.id)
|
||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
||||
message.parts.forEach((part) => {
|
||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
partIDs.add(part.id)
|
||||
})
|
||||
})
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function waitForVisualSettle(page: Page, selectors: string[], stableFrames = 3) {
|
||||
await page.waitForFunction(
|
||||
({ selectors, stableFrames }) => {
|
||||
const elements = selectors.map((selector) => document.querySelector<HTMLElement>(selector))
|
||||
if (elements.some((element) => !element)) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let stable = 0
|
||||
let previous = ""
|
||||
const sample = () => {
|
||||
const signature = JSON.stringify(
|
||||
elements.map((element) => {
|
||||
const rect = element!.getBoundingClientRect()
|
||||
return [Math.round(rect.top * 10), Math.round(rect.bottom * 10), Math.round(rect.height * 10)]
|
||||
}),
|
||||
)
|
||||
stable = signature === previous ? stable + 1 : 0
|
||||
previous = signature
|
||||
const ordered = elements
|
||||
.slice(1)
|
||||
.every(
|
||||
(element, index) =>
|
||||
elements[index]!.getBoundingClientRect().bottom <= element!.getBoundingClientRect().top + 0.5,
|
||||
)
|
||||
if (stable >= stableFrames && ordered) return resolve(true)
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
},
|
||||
{ selectors, stableFrames },
|
||||
)
|
||||
}
|
||||
|
||||
export function historyMessages(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const value = String(index).padStart(4, "0")
|
||||
const historyUserID = `msg_0${value}_history_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: historyUserID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[
|
||||
{
|
||||
id: `prt_0${value}_history_text`,
|
||||
type: "text",
|
||||
text: `Historical response ${index}. ${"Existing session content keeps the virtual timeline realistic. ".repeat(5)}`,
|
||||
},
|
||||
],
|
||||
{
|
||||
id: `msg_0${value}_history_b_assistant`,
|
||||
parentID: historyUserID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
||||
decodePart(owned, decodeOptions)
|
||||
return event("message.part.updated", {
|
||||
sessionID,
|
||||
part: owned,
|
||||
time: 1700000002000,
|
||||
})
|
||||
}
|
||||
|
||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
||||
}
|
||||
|
||||
export function messageUpdated(info: Message) {
|
||||
return event("message.updated", { sessionID, info })
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
return event("session.status", {
|
||||
sessionID,
|
||||
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
||||
})
|
||||
}
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
summary: input.summary ?? { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: seeds.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID: id,
|
||||
})),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
parts: PartSeed<"assistant">[] = [],
|
||||
input: {
|
||||
id?: string
|
||||
parentID?: string
|
||||
completed?: boolean
|
||||
error?: AssistantMessage["error"]
|
||||
created?: number
|
||||
} = {},
|
||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
||||
const id = input.id ?? assistantID
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: input.created ?? 1700000001000,
|
||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
||||
},
|
||||
parentID: input.parentID ?? userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
}
|
||||
|
||||
export function userText(
|
||||
text: string,
|
||||
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
||||
): Extract<PartSeed<"user">, { type: "text" }> {
|
||||
return { id: "prt_user_text", type: "text", text, ...input }
|
||||
}
|
||||
|
||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
||||
return { id, type: "text", text }
|
||||
}
|
||||
|
||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||
}
|
||||
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "pending",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "running",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"running">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "completed",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"completed">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "error",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"error">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: ToolStatus,
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
const base = { id, type: "tool" as const, callID: `call_${id}`, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
title: options.title,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000 },
|
||||
},
|
||||
}
|
||||
if (state === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
error: options.error ?? "Tool failed",
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: state,
|
||||
input,
|
||||
output: options.output ?? "Completed",
|
||||
title: options.title ?? tool,
|
||||
metadata: options.metadata ?? {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function shell(
|
||||
id: string,
|
||||
state: ToolStatus,
|
||||
output = "",
|
||||
command = `echo ${id}`,
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||
if (state === "running")
|
||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||
if (state === "error")
|
||||
return toolPart(id, "bash", state, { command }, { error: output || undefined, metadata: { command, output } })
|
||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||
}
|
||||
|
||||
export function project() {
|
||||
return {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "timeline-stability",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function session(input: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: sessionID,
|
||||
slug: "timeline-stability",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function eventSchema<
|
||||
const Type extends TimelinePayload["type"],
|
||||
const Properties extends Schema.Codec<unknown, unknown>,
|
||||
>(type: Type, properties: Properties) {
|
||||
return Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
||||
})
|
||||
}
|
||||
|
||||
function provider() {
|
||||
return {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
const followingID = "prt_interaction_02_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "completed", lines(50)), textPart(followingID, "Following shell expansion")]),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
const plan = visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(500)
|
||||
const expanded = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-expand", expanded, plan)
|
||||
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await page.waitForTimeout(500)
|
||||
const collapsed = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "shell-collapse", collapsed, plan)
|
||||
})
|
||||
|
||||
test("expands and collapses a completed context group without overlap", async ({ page }, testInfo) => {
|
||||
const ids = [
|
||||
"prt_interaction_01_read",
|
||||
"prt_interaction_02_glob",
|
||||
"prt_interaction_03_grep",
|
||||
"prt_interaction_04_list",
|
||||
]
|
||||
const group = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const followingID = "prt_interaction_context_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart(ids[2]!, "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
toolPart(ids[3]!, "list", "completed", { path: "src" }),
|
||||
textPart(followingID, "Following context expansion"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
||||
for (const [name, expanded] of [
|
||||
["context-expand", true],
|
||||
["context-collapse", false],
|
||||
["context-reexpand", true],
|
||||
] as const) {
|
||||
const regions = defineVisualRegions({
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await page.waitForTimeout(500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("expands and collapses an edit diff without moving twice", async ({ page }, testInfo) => {
|
||||
const editID = "prt_interaction_edit"
|
||||
const followingID = "prt_interaction_edit_following"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
editID,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/edit.ts",
|
||||
additions: 40,
|
||||
deletions: 40,
|
||||
before: source(40, false),
|
||||
after: source(40, true),
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
textPart(followingID, "Following edit expansion"),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"edit-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["edit", "following"] },
|
||||
{ type: "unique", regions: ["edit", "following"] },
|
||||
{ type: "stable", regions: ["edit", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["edit", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
const nextUserID = "msg_2000_diff_interaction_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
firstUser,
|
||||
assistantMessage(),
|
||||
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_2001_diff_interaction_assistant",
|
||||
parentID: nextUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
||||
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
||||
await expect(diff).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
||||
following: { selector: `[data-message-id="${nextUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByText(/show all/i).click()
|
||||
await page.waitForTimeout(500)
|
||||
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"diff-summary-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["diff", "following"] },
|
||||
{ type: "unique", regions: ["diff", "following"] },
|
||||
{ type: "stable", regions: ["diff", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["diff", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function source(count: number, changed: boolean) {
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`,
|
||||
).join("")
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
mapVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline visual lifecycle stability", () => {
|
||||
test("streams empty, short, and long parallel shells to staggered completion", async ({ page }, testInfo) => {
|
||||
test.setTimeout(180_000)
|
||||
const ids = ["prt_parallel_01_empty", "prt_parallel_02_short", "prt_parallel_03_long"] as const
|
||||
const initial = ids.map((id) => shell(id, "running"))
|
||||
const followingID = "prt_parallel_04_following"
|
||||
const assistant = assistantMessage([...initial, textPart(followingID, "Following all parallel shells.")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { shellToolPartsExpanded: true, showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
eventRetry: 24,
|
||||
seedHistory: true,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
for (const id of ids) await timeline.waitForPart(id)
|
||||
const scroller = page.locator(".scroll-view__viewport", {
|
||||
has: page.locator('[data-timeline-row="AssistantPart"]'),
|
||||
})
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
const regions = defineVisualRegions({
|
||||
prt_shell_empty: shellRegion(ids[0]),
|
||||
prt_shell_short: shellRegion(ids[1]),
|
||||
prt_shell_long: shellRegion(ids[2]),
|
||||
following: shellRegion(followingID),
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.sendAll([
|
||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(10))), delay: 70 },
|
||||
{ event: partUpdated(shell(ids[1]!, "running", lines(2))), delay: 110 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(25))), delay: 80 },
|
||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
||||
{ event: status("idle"), delay: 700 },
|
||||
])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"parallel-shells",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "unique", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long"] },
|
||||
{ type: "stable", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["prt_shell_empty", "prt_shell_short", "prt_shell_long", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
||||
|
||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("replaces thinking with streamed reasoning and text without a blank visible turn", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const reasoningID = "prt_reasoning_visible"
|
||||
const textID = "prt_streamed_text"
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
reasoning: {
|
||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||
await timeline.waitForPart(reasoningID)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"reasoning-text-handoff",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["reasoning", "text"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "reasoning", "text"] },
|
||||
{ type: "unique", regions: ["reasoning", "text"] },
|
||||
{ type: "stable", regions: ["reasoning", "text"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["reasoning", "text"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
||||
})
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function shellRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const regions = defineVisualRegions({
|
||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
element.parentElement!.style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_descendant_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
await row.evaluate((element) => {
|
||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
content: {
|
||||
selector: `[data-timeline-part-id="${partID}"]`,
|
||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "0"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
await row.evaluate((element) => {
|
||||
;(element as HTMLElement).style.opacity = "1"
|
||||
})
|
||||
await page.waitForTimeout(50)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import config from "../playwright.config"
|
||||
|
||||
export default {
|
||||
...config,
|
||||
testDir: ".",
|
||||
testMatch: "**/*.spec.ts",
|
||||
outputDir: "../../test-results/timeline-stability",
|
||||
reporter: [["html", { outputFolder: "../../playwright-report/timeline-stability", open: "never" }], ["line"]],
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
...config.use,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
type TimelineMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("does not reverse visible rows when the user wheels during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_wheel_01_shell"
|
||||
const followingID = "prt_wheel_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following wheel interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||
await scroller.evaluate((element) =>
|
||||
element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -180 })),
|
||||
)
|
||||
await scroller.evaluate((element) => (element.scrollTop -= 180))
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 250)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_keyboard_01_shell"
|
||||
const followingID = "prt_keyboard_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(12),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following keyboard interaction")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await scroller.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop), {
|
||||
timeout: 20_000,
|
||||
})
|
||||
.toBeGreaterThan(80)
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 400)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "keyboard-during-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("tracks keyboard scrolling from a focused timeline descendant", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_descendant_keyboard_01_shell"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [...history(12), userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||
await row.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
await row.press("PageUp")
|
||||
await page.waitForTimeout(250)
|
||||
}
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(5)
|
||||
const anchor = await scroller.evaluate((element) => {
|
||||
const view = element.getBoundingClientRect()
|
||||
return [...element.querySelectorAll<HTMLElement>("[data-timeline-key]")].find((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.top >= view.top + 40 && rect.bottom <= view.bottom - 40
|
||||
})?.dataset.timelineKey
|
||||
})
|
||||
expect(anchor).toBeTruthy()
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-key="${anchor}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "descendant-keyboard-resize", trace, anchorPlan(regions))
|
||||
})
|
||||
|
||||
test("does not claim keyboard scrolling owned by a nested scrollable", async ({ page }) => {
|
||||
const shellID = "prt_nested_keyboard_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(50))])],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
reducedMotion: true,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await nested.focus()
|
||||
await page.waitForFunction(() => {
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
if (!root) return false
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const top = root.scrollTop
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve(Math.abs(root.scrollTop - top) <= 0.5)))
|
||||
})
|
||||
})
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
const nestedBefore = await nested.evaluate((element) => element.scrollTop)
|
||||
await nested.press("PageUp")
|
||||
await page.waitForTimeout(300)
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
expect(await nested.evaluate((element) => element.scrollTop)).toBeLessThan(nestedBefore)
|
||||
|
||||
await nested.evaluate((element) => (element.scrollTop = 0))
|
||||
await scroller.evaluate((element) => (element.scrollTop = Math.min(300, element.scrollHeight - element.clientHeight)))
|
||||
const boundaryBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
expect(boundaryBefore).toBeGreaterThan(0)
|
||||
await nested.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
await nonOverflowing.evaluate((element) => {
|
||||
element.setAttribute("data-scrollable", "")
|
||||
element.setAttribute("tabindex", "0")
|
||||
})
|
||||
await nonOverflowing.focus()
|
||||
const nonOverflowBefore = await scroller.evaluate((element) => element.scrollTop)
|
||||
await nonOverflowing.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(nonOverflowBefore)
|
||||
})
|
||||
|
||||
test("jump to latest lands on stable final rows after offscreen growth", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_jump_01_shell"
|
||||
const followingID = "prt_jump_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
...history(20),
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Latest visible row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate(
|
||||
(element) => (element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 600)),
|
||||
)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"jump-latest",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "acquire-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles a single row taller than the viewport", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_tall_01_shell"
|
||||
const followingID = "prt_tall_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "After tall row")], { completed: false }),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
viewport: { width: 900, height: 360 },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"taller-than-viewport",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function history(count: number): TimelineMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const prefix = `msg_${String(index).padStart(4, "0")}_scroll`
|
||||
const userID = `${prefix}_a_user`
|
||||
return [
|
||||
userMessage(undefined, { id: userID, created: 1690000000000 + index * 10_000 }),
|
||||
assistantMessage(
|
||||
[textPart(`prt_${String(index).padStart(4, "0")}_scroll`, `History ${index}. ${"content ".repeat(30)}`)],
|
||||
{
|
||||
id: `${prefix}_b_assistant`,
|
||||
parentID: userID,
|
||||
created: 1690000001000 + index * 10_000,
|
||||
},
|
||||
),
|
||||
]
|
||||
}).flat()
|
||||
}
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
function rowPairPlan(
|
||||
regions: Record<"shell" | "following", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
])
|
||||
}
|
||||
|
||||
function anchorPlan(regions: Record<"anchor", { selector: string; closest?: string }>) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "fixed", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const profiles = [
|
||||
{
|
||||
name: "empty running to completed",
|
||||
updates: [{ state: "completed" as const, output: "", delay: 350 }],
|
||||
},
|
||||
{
|
||||
name: "50 lines arriving incrementally",
|
||||
updates: [
|
||||
{ state: "running" as const, output: lines(1), delay: 100 },
|
||||
{ state: "running" as const, output: lines(10), delay: 160 },
|
||||
{ state: "running" as const, output: lines(25), delay: 90 },
|
||||
{ state: "running" as const, output: lines(50), delay: 220 },
|
||||
{ state: "completed" as const, output: lines(50), delay: 500 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "wide ANSI and CRLF output",
|
||||
updates: [
|
||||
{
|
||||
state: "running" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `\u001b[32mline ${index}\u001b[0m ${"wide-".repeat(30)}`).join(
|
||||
"\r\n",
|
||||
),
|
||||
delay: 240,
|
||||
},
|
||||
{
|
||||
state: "completed" as const,
|
||||
output: Array.from({ length: 20 }, (_, index) => `line ${index} ${"wide-".repeat(30)}`).join("\n"),
|
||||
delay: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`keeps rows stable for shell ${profile.name}`, async ({ page }, testInfo) => {
|
||||
const shellID = `prt_matrix_${profiles.indexOf(profile)}_01_shell`
|
||||
const followingID = `prt_matrix_${profiles.indexOf(profile)}_02_following`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following shell row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const update of profile.updates) {
|
||||
await timeline.send(partUpdated(shell(shellID, update.state, update.output)), update.delay)
|
||||
}
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
`shell-${profiles.indexOf(profile)}`,
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0, maxReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps following row stable when a collapsed shell receives 50 lines", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_collapsed_01_shell"
|
||||
const followingID = "prt_matrix_collapsed_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running"), textPart(followingID, "Following collapsed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(50))), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"collapsed-shell",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when a running shell becomes an error", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_matrix_error_01_shell"
|
||||
const followingID = "prt_matrix_error_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([shell(shellID, "running", lines(10)), textPart(followingID, "Following failed shell")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated({
|
||||
...shell(shellID, "error"),
|
||||
state: {
|
||||
status: "error",
|
||||
input: { command: `echo ${shellID}` },
|
||||
error: "Command failed after output",
|
||||
metadata: {},
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
}),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"shell-error",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps rows stable when later text arrives before shell output", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_late_text_01_shell"
|
||||
const followingID = "prt_late_text_02_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "running")], { completed: false })],
|
||||
settings: { shellToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"late-text-before-shell-output",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
{ type: "unique", regions: ["shell", "following"] },
|
||||
{ type: "stable", regions: ["shell"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["shell", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("adds a task child-session link without replacing the task row", async ({ page }, testInfo) => {
|
||||
const taskID = "prt_task_link"
|
||||
const childID = "ses_task_child"
|
||||
const input = { description: "Inspect child", subagent_type: "explore" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([toolPart(taskID, "task", "running", input)], { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect child" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(taskID, "task", "completed", input, { metadata: { sessionId: childID } })),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"task-link",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["task"] },
|
||||
{ type: "unique", regions: ["task"] },
|
||||
{ type: "stable", regions: ["task"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
||||
const toolID = "prt_generic_mutation"
|
||||
const followingID = "prt_generic_mutation_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
||||
textPart(followingID, "Following generic tool"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
||||
200,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
||||
400,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"generic-mutation",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantMessage,
|
||||
directory,
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test.describe("timeline tool state stability", () => {
|
||||
test("moves lightweight tools through pending, running, and completed without replacing rows", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["webfetch", "websearch", "task", "skill", "custom"] as const
|
||||
const inputs = {
|
||||
webfetch: { url: "https://example.com/docs" },
|
||||
websearch: { query: "timeline stability" },
|
||||
task: { description: "Inspect timeline", subagent_type: "explore" },
|
||||
skill: { name: "stability" },
|
||||
custom: { target: "timeline", depth: 2 },
|
||||
}
|
||||
const names = { webfetch: "webfetch", websearch: "websearch", task: "task", skill: "skill", custom: "mcp_probe" }
|
||||
const questionID = "prt_state_question"
|
||||
const todoID = "prt_state_todo"
|
||||
const initial = [
|
||||
...ids.map((id) => toolPart(`prt_state_${id}`, names[id], "pending", inputs[id])),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
toolPart(todoID, "todowrite", "pending", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
textPart("prt_state_following", "Following lightweight tools"),
|
||||
]
|
||||
const childID = "ses_timeline_child"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(initial, { completed: false })],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Inspect timeline" })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
"prt_state_websearch",
|
||||
"prt_state_task",
|
||||
"prt_state_skill",
|
||||
"prt_state_custom",
|
||||
] as const
|
||||
const regions = defineVisualRegions({
|
||||
prt_state_webfetch: toolRegion(regionIDs[0]),
|
||||
prt_state_websearch: toolRegion(regionIDs[1]),
|
||||
prt_state_task: toolRegion(regionIDs[2]),
|
||||
prt_state_skill: toolRegion(regionIDs[3]),
|
||||
prt_state_custom: toolRegion(regionIDs[4]),
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, id] of ids.entries()) {
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${id}`, names[id], "running", inputs[id])),
|
||||
[80, 240, 100, 360, 140][index],
|
||||
)
|
||||
}
|
||||
for (const [index, id] of ["skill", "webfetch", "custom", "task", "websearch"].entries()) {
|
||||
const key = id as (typeof ids)[number]
|
||||
const metadata = key === "task" ? { sessionId: childID } : key === "websearch" ? { provider: "exa" } : {}
|
||||
const output = key === "websearch" ? "Result https://example.com/result" : "Completed"
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(`prt_state_${key}`, names[key], "completed", inputs[key], { metadata, output })),
|
||||
[110, 70, 280, 130, 420][index],
|
||||
)
|
||||
}
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", questionInput(), { metadata: { answers: [["Keep it stable"]] } }),
|
||||
),
|
||||
350,
|
||||
)
|
||||
await timeline.waitForPart(questionID)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"lightweight-tools",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: regionIDs },
|
||||
{ type: "unique", regions: regionIDs },
|
||||
{ type: "stable", regions: regionIDs },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps an expanded mixed context group stable through staggered completion and error", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_ctx_01_read", "prt_ctx_02_glob", "prt_ctx_03_grep", "prt_ctx_04_list"]
|
||||
const tools = ["read", "glob", "grep", "list"]
|
||||
const inputs = [
|
||||
{ filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "stability", include: "*.ts" },
|
||||
{ path: "src" },
|
||||
]
|
||||
const context = ids.map((id, index) => toolPart(id, tools[index]!, "pending", inputs[index]!))
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([...context, textPart("prt_ctx_following", "Following context")], { completed: false }),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 100)
|
||||
const groupSelector = `[data-timeline-part-ids="${ids.join(",")}"]`
|
||||
const group = page.locator(groupSelector)
|
||||
await expect(group).toBeVisible()
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${groupSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [90, 260, 70, 380].entries()) {
|
||||
await timeline.send(partUpdated(toolPart(ids[index]!, tools[index]!, "running", inputs[index]!)), delay)
|
||||
}
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, tools[1]!, "completed", inputs[1]!)), 130)
|
||||
await timeline.send(partUpdated(toolPart(ids[3]!, tools[3]!, "completed", inputs[3]!)), 210)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(ids[0]!, tools[0]!, "error", inputs[0]!, { error: "Read interrupted" })),
|
||||
110,
|
||||
)
|
||||
await timeline.send(partUpdated(toolPart(ids[2]!, tools[2]!, "completed", inputs[2]!)), 250)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await timeline.send(status("idle"), 700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"mixed-context",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context"] },
|
||||
{ type: "stable", regions: ["context"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxReversals: 4 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_ctx_late_sibling", "Later sibling content")), 200)
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(group.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function toolRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
event,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_mutation_01_first"
|
||||
const middleID = "prt_mutation_02_middle"
|
||||
const lastID = "prt_mutation_03_last"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
const followingID = "prt_text_reconcile_following"
|
||||
const assistant = assistantMessage([textPart(textID, "Starting"), textPart(followingID, "Following text row")], {
|
||||
completed: false,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
const regions = defineVisualRegions({
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
||||
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
||||
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"text-reconcile",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["text", "following"] },
|
||||
{ type: "unique", regions: ["text", "following"] },
|
||||
{ type: "stable", regions: ["text", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["text", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("inserts a completed question between stable rows", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_question_01_first"
|
||||
const questionID = "prt_question_02_hidden"
|
||||
const lastID = "prt_question_03_last"
|
||||
const input = { questions: [{ header: "Choice", question: "Keep stable?", options: [] }] }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
textPart(firstID, "Before question"),
|
||||
toolPart(questionID, "question", "running", input),
|
||||
textPart(lastID, "After question"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||
600,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||
})
|
||||
|
||||
test("replaces thinking with an assistant error without a blank turn", async ({ page }, testInfo) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
await timeline.send(status("busy"), 150)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
error: { selector: '[data-timeline-row="Error"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
messageUpdated({
|
||||
...assistant.info,
|
||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
||||
}),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Error"]')).toContainText("Provider failed visibly")
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"thinking-error",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["thinking", "error"] },
|
||||
{ type: "continuous-any", regions: ["thinking", "error"] },
|
||||
{ type: "unique", regions: ["thinking", "error"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
test("updates retry attempts and long provider messages without remounting the retry row", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([], { completed: false })],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("retry", 1), 120)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
retry: { selector: '[data-timeline-row="Retry"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("session.status", {
|
||||
sessionID: "ses_timeline_stability",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "A very long provider retry message ".repeat(8),
|
||||
next: Date.now() + 10_000,
|
||||
},
|
||||
}),
|
||||
300,
|
||||
)
|
||||
await timeline.send(status("retry", 3), 300)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"retry-evolution",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["retry"] },
|
||||
{ type: "unique", regions: ["retry"] },
|
||||
{ type: "stable", regions: ["retry"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const removeUserID = "msg_0500_remove_user"
|
||||
const removeAssistantID = "msg_0501_remove_assistant"
|
||||
const anchorUserID = "msg_2000_anchor_user"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
||||
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
||||
id: removeAssistantID,
|
||||
parentID: removeUserID,
|
||||
created: 1690000001000,
|
||||
}),
|
||||
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
||||
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
||||
id: "msg_2001_anchor_assistant",
|
||||
parentID: anchorUserID,
|
||||
created: 1700000001000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
||||
200,
|
||||
)
|
||||
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"historical-turn-remove",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function stablePairPlan(
|
||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
) {
|
||||
return visualPlan(regions, [
|
||||
{ type: "required", regions: ["first", "last"] },
|
||||
{ type: "unique", regions: ["first", "last"] },
|
||||
{ type: "stable", regions: ["first", "last"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
])
|
||||
}
|
||||
@@ -2,7 +2,13 @@ import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, mockStressTimeline, stressSessionHref } from "./timeline-test-helpers"
|
||||
import {
|
||||
createReviewDiffs,
|
||||
installStressSessionTabs,
|
||||
installTimelineSettings,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
||||
@@ -20,8 +26,41 @@ benchmark("benchmarks cold and hot session tab switching", async ({ browser, rep
|
||||
report({ results, summary: summarize(results) })
|
||||
})
|
||||
|
||||
async function trial(page: Page, mode: "cold" | "hot") {
|
||||
await mockStressTimeline(page)
|
||||
benchmark(
|
||||
"benchmarks v2 session tab switching with and without the review pane",
|
||||
async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(360_000)
|
||||
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
|
||||
const results = {
|
||||
closed: { cold: [] as Result[], hot: [] as Result[] },
|
||||
open: { cold: [] as Result[], hot: [] as Result[] },
|
||||
}
|
||||
for (const reviewPane of ["closed", "open"] as const) {
|
||||
for (const mode of ["cold", "hot"] as const) {
|
||||
for (let run = 0; run < runs; run++) {
|
||||
results[reviewPane][mode].push(
|
||||
await withBenchmarkPage(
|
||||
browser,
|
||||
`session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
|
||||
(page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
|
||||
testInfo,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
report({ results, summary: summarizeReviewPane(results) }, { runs, reviewDiffs: createReviewDiffs().length })
|
||||
},
|
||||
)
|
||||
|
||||
async function trial(
|
||||
page: Page,
|
||||
mode: "cold" | "hot",
|
||||
options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
|
||||
) {
|
||||
const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
|
||||
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
|
||||
if (options?.newLayoutDesigns) await installTimelineSettings(page)
|
||||
await installStressSessionTabs(page)
|
||||
if (mode === "hot") {
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
@@ -33,6 +72,10 @@ async function trial(page: Page, mode: "cold" | "hot") {
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
}
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
if (options?.reviewPane === "open") {
|
||||
await openReviewPane(page)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||
@@ -70,6 +113,15 @@ function summarize(results: Record<"cold" | "hot", Result[]>) {
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeReviewPane(results: Record<"closed" | "open", Record<"cold" | "hot", Result[]>>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(results).map(([reviewPane, values]) => [
|
||||
reviewPane,
|
||||
summarize(values as Record<"cold" | "hot", Result[]>),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = stressSessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
@@ -77,3 +129,16 @@ async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
async function openReviewPane(page: Page) {
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(panel).toBeVisible()
|
||||
// Text-based readiness works across review implementations; the legacy list mounts
|
||||
// diff viewers lazily while V2 mounts the active preview eagerly.
|
||||
await page.waitForFunction(() => {
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const text = panel?.textContent ?? ""
|
||||
return text.includes("generated-000.ts") && text.includes("+3")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ export type SessionSwitchSample = {
|
||||
hasVisibleRows: boolean
|
||||
last: boolean
|
||||
bottomErrorPx?: number
|
||||
review?: {
|
||||
fileHost: boolean
|
||||
fileHostReplaced: boolean
|
||||
header: string
|
||||
replacedLevels: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||
@@ -23,6 +29,10 @@ export function classifySessionSwitch(samples: SessionSwitchSample[]) {
|
||||
(sample) => sample.hasVisibleRows && sample.destination.length === 0 && sample.source.length === 0,
|
||||
).length,
|
||||
sourceSamples: samples.filter((sample) => sample.source.length > 0).length,
|
||||
reviewFileHostMissingSamples: samples.filter((sample) => sample.review && !sample.review.fileHost).length,
|
||||
reviewFileHostReplacedSamples: samples.filter((sample) => sample.review?.fileHostReplaced).length,
|
||||
reviewHeaders: [...new Set(samples.flatMap((sample) => (sample.review ? [sample.review.header] : [])))],
|
||||
reviewReplacedLevels: [...new Set(samples.flatMap((sample) => sample.review?.replacedLevels ?? []))],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,41 @@ async function installSessionSwitchProbe(
|
||||
const samples: SessionSwitchSample[] = []
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
const reviewLevels: Record<string, string> = {
|
||||
panel: "#review-panel",
|
||||
tabs: '#review-panel [data-component="tabs"]',
|
||||
body: '#review-panel [data-slot="session-review-v2-body"]',
|
||||
review: '#review-panel [data-component="session-review-v2"]',
|
||||
preview: '#review-panel [data-slot="session-review-v2-preview"]',
|
||||
scroll: '#review-panel [data-slot="session-review-v2-diff-scroll"]',
|
||||
file: '#review-panel [data-component="file"][data-mode="diff"]',
|
||||
}
|
||||
const initialReviewNodes: Record<string, Element | null> = {}
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
const observedAtMs = performance.now() - started
|
||||
const reviewPanel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const reviewFile = reviewPanel?.querySelector('[data-component="file"][data-mode="diff"]')
|
||||
const initialReviewFile = initialReviewNodes.file
|
||||
const replacedLevels = Object.entries(reviewLevels).flatMap(([name, selector]) => {
|
||||
const initial = initialReviewNodes[name]
|
||||
if (!initial) return []
|
||||
const current = document.querySelector(selector)
|
||||
return current && current !== initial ? [name] : []
|
||||
})
|
||||
const review = reviewPanel
|
||||
? {
|
||||
fileHost: !!reviewFile,
|
||||
fileHostReplaced: !!initialReviewFile && !!reviewFile && reviewFile !== initialReviewFile,
|
||||
header:
|
||||
reviewPanel
|
||||
.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')
|
||||
?.textContent?.trim() ?? "",
|
||||
replacedLevels,
|
||||
}
|
||||
: undefined
|
||||
const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
@@ -44,9 +74,10 @@ async function installSessionSwitchProbe(
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false })
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
@@ -57,6 +88,9 @@ async function installSessionSwitchProbe(
|
||||
const link = event.target instanceof Element ? event.target.closest("a") : undefined
|
||||
if (link?.getAttribute("href") !== href) return
|
||||
started = performance.now()
|
||||
for (const [name, selector] of Object.entries(reviewLevels)) {
|
||||
initialReviewNodes[name] = document.querySelector(selector)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
|
||||
@@ -93,36 +93,53 @@ const assistantMessage = {
|
||||
parts: [editPart],
|
||||
}
|
||||
|
||||
export async function setupTimelineBenchmark(page: Page, options: { historyTurns: number; eventBatch: number }) {
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
options: {
|
||||
historyTurns: number
|
||||
eventBatch: number
|
||||
newLayoutDesigns?: boolean
|
||||
vcsDiff?: unknown[]
|
||||
turnDiffs?: unknown[]
|
||||
},
|
||||
) {
|
||||
const events: EventPayload[] = []
|
||||
let eventBatch = options.eventBatch
|
||||
const currentUserMessage = options.turnDiffs
|
||||
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
||||
: userMessage
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
vcsDiff: options.vcsDiff,
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
...Array.from({ length: options.historyTurns }, (_, index) => performanceTurn(index)).flat(),
|
||||
userMessage,
|
||||
currentUserMessage,
|
||||
assistantMessage,
|
||||
],
|
||||
}),
|
||||
events: () => events.splice(0, eventBatch),
|
||||
eventRetry: 16,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await page.addInitScript(
|
||||
(input) => {
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
general: {
|
||||
newLayoutDesigns: input.newLayoutDesigns,
|
||||
editToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: true,
|
||||
showReasoningSummaries: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
|
||||
)
|
||||
await page.setViewportSize({ width: 1366, height: 768 })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, benchmarkDiagnostics, expect } from "../benchmark"
|
||||
import {
|
||||
buildInitialStreamEvent,
|
||||
@@ -6,80 +7,300 @@ import {
|
||||
textPartID,
|
||||
} from "./session-timeline-benchmark.fixture"
|
||||
import { startTimelineProfile } from "./session-timeline-profile"
|
||||
import { createReviewDiffs } from "./timeline-test-helpers"
|
||||
import {
|
||||
collectTimelineStreamMetrics,
|
||||
installTimelineStreamProbe,
|
||||
startTimelineStreamProbe,
|
||||
} from "./session-timeline-stream-probe"
|
||||
|
||||
type TimelineStreamOptions = {
|
||||
newLayoutDesigns?: boolean
|
||||
reviewDiffs?: boolean
|
||||
reviewPane?: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneSample = {
|
||||
observedAtMs: number
|
||||
panelVisible: boolean
|
||||
header: string
|
||||
diffViewers: number
|
||||
diffLines: number
|
||||
codeBlocks: number
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneProbe = {
|
||||
samples: ReviewPaneSample[]
|
||||
start: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
const reviewReadyStreak = 3
|
||||
|
||||
benchmark.describe("performance: session timeline streaming", () => {
|
||||
benchmark("streams assistant text without remounting or oscillating", async ({ page, report }) => {
|
||||
benchmark.setTimeout(480_000)
|
||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, {})
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
|
||||
benchmark("streams assistant text in v2 with review pane open", async ({ page, report }) => {
|
||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewPane: true })
|
||||
report(result.metrics, result.context)
|
||||
})
|
||||
})
|
||||
|
||||
benchmark.describe("performance: review pane", () => {
|
||||
benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
|
||||
benchmark.setTimeout(240_000)
|
||||
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
|
||||
const diffs = createReviewDiffs()
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
eventBatch: 1,
|
||||
newLayoutDesigns: true,
|
||||
vcsDiff: diffs,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
||||
const contentStart = performance.now()
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(1))
|
||||
await expect(fixture.text).toBeVisible()
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
const initialContentObservedMs = performance.now() - contentStart
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
|
||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
||||
await startTimelineStreamProbe(page)
|
||||
fixture.transport.enqueue(deltas)
|
||||
|
||||
await page.waitForFunction(
|
||||
(finalIndex) =>
|
||||
(
|
||||
window as Window & {
|
||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
||||
}
|
||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
||||
deltaCount,
|
||||
{ timeout: 420_000 },
|
||||
)
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text).toContainText("Streaming")
|
||||
await fixture.waitForStableGeometry()
|
||||
const metrics = await collectTimelineStreamMetrics(page, {
|
||||
textPartID,
|
||||
finalIndex: deltaCount,
|
||||
navigations: benchmarkDiagnostics(page).navigations,
|
||||
})
|
||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
||||
await profile.stop()
|
||||
const open = await measureReviewPaneLoad(page, diffs[0]!.file)
|
||||
const switches = []
|
||||
for (const diff of diffs.slice(1, 4)) switches.push(await measureReviewNextFile(page, diff.file))
|
||||
|
||||
report(
|
||||
{
|
||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||
...metrics,
|
||||
deliveredDeltas: delivered,
|
||||
pendingDeltas: fixture.transport.pendingCount(),
|
||||
open,
|
||||
switches,
|
||||
},
|
||||
{
|
||||
cpuThrottle,
|
||||
profileCPU,
|
||||
profileVisual,
|
||||
minimal,
|
||||
queuedDeltas: deltas.length,
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
reviewDiffs: diffs.length,
|
||||
},
|
||||
)
|
||||
|
||||
await profile.reset()
|
||||
})
|
||||
})
|
||||
|
||||
async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOptions) {
|
||||
const completionTimeoutMs = Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000)
|
||||
const cpuThrottle = Number(process.env.TIMELINE_CPU_THROTTLE ?? 30)
|
||||
const deltaCount = Number(process.env.TIMELINE_DELTA_COUNT ?? 160)
|
||||
const historyTurns = Number(process.env.TIMELINE_HISTORY_TURNS ?? 320)
|
||||
const eventBatch = Number(process.env.TIMELINE_EVENT_BATCH ?? 1)
|
||||
const minimal = process.env.TIMELINE_MINIMAL === "1"
|
||||
const profileCPU = process.env.TIMELINE_CPU_PROFILE === "1"
|
||||
const profileVisual = !minimal && profileCPU && process.env.TIMELINE_VISUAL_PROFILE !== "0"
|
||||
const diffs = options.reviewDiffs || options.reviewPane ? createReviewDiffs() : undefined
|
||||
const fixture = await setupTimelineBenchmark(page, {
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
newLayoutDesigns: options.newLayoutDesigns,
|
||||
// Turn diffs exercise timeline data cost; the pane-open scenario serves the same
|
||||
// diffs through the default git mode so it works across review implementations.
|
||||
turnDiffs: options.reviewDiffs ? diffs : undefined,
|
||||
vcsDiff: options.reviewPane ? diffs : undefined,
|
||||
})
|
||||
|
||||
fixture.transport.enqueue(buildInitialStreamEvent(deltaCount))
|
||||
const contentStart = performance.now()
|
||||
await expect(fixture.text).toBeVisible()
|
||||
await expect(fixture.text).toContainText("Implementation plan")
|
||||
const initialContentObservedMs = performance.now() - contentStart
|
||||
await fixture.scrollToBottom()
|
||||
await fixture.waitForStableGeometry()
|
||||
|
||||
const reviewPane = options.reviewPane && diffs ? await measureReviewPaneLoad(page, diffs[0]!.file) : undefined
|
||||
if (reviewPane) await fixture.waitForStableGeometry()
|
||||
|
||||
const profile = await startTimelineProfile(page, { cpuThrottle, profileCPU })
|
||||
await installTimelineStreamProbe(page, { textPartID, finalIndex: deltaCount, profileVisual, minimal })
|
||||
const deltas = buildStreamDeltaEvents(deltaCount)
|
||||
await startTimelineStreamProbe(page)
|
||||
fixture.transport.enqueue(deltas)
|
||||
|
||||
await page.waitForFunction(
|
||||
(finalIndex) =>
|
||||
(
|
||||
window as Window & {
|
||||
__timelineStreamBenchmark?: { applied: { index: number }[] }
|
||||
}
|
||||
).__timelineStreamBenchmark?.applied.some((value) => value.index === finalIndex),
|
||||
deltaCount,
|
||||
{ timeout: completionTimeoutMs },
|
||||
)
|
||||
await expect(fixture.text).toContainText("benchmark-complete")
|
||||
await expect(fixture.text).toContainText("Streaming")
|
||||
await fixture.waitForStableGeometry()
|
||||
const metrics = await collectTimelineStreamMetrics(page, {
|
||||
textPartID,
|
||||
finalIndex: deltaCount,
|
||||
navigations: benchmarkDiagnostics(page).navigations,
|
||||
})
|
||||
const delivered = deltas.length - fixture.transport.pendingCount()
|
||||
await profile.stop()
|
||||
|
||||
const result = {
|
||||
metrics: {
|
||||
endToEndInitialContentObservedMs: initialContentObservedMs,
|
||||
...metrics,
|
||||
deliveredDeltas: delivered,
|
||||
pendingDeltas: fixture.transport.pendingCount(),
|
||||
reviewPane: reviewPane ?? null,
|
||||
},
|
||||
context: {
|
||||
cpuThrottle,
|
||||
profileCPU,
|
||||
profileVisual,
|
||||
minimal,
|
||||
queuedDeltas: deltas.length,
|
||||
historyTurns,
|
||||
eventBatch,
|
||||
newLayoutDesigns: options.newLayoutDesigns === true,
|
||||
reviewPane: options.reviewPane === true ? "open" : "closed",
|
||||
reviewDiffs: diffs?.length ?? 0,
|
||||
},
|
||||
}
|
||||
|
||||
await profile.reset()
|
||||
return result
|
||||
}
|
||||
|
||||
async function measureReviewPaneLoad(page: Page, file: string) {
|
||||
// Default git mode reads the mocked /vcs/diff data, so opening the pane is enough
|
||||
// and the flow works across review pane implementations.
|
||||
await installReviewPaneProbe(page, { file })
|
||||
await startReviewPaneProbe(page)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
return collectReviewPaneProbe(page)
|
||||
}
|
||||
|
||||
async function measureReviewNextFile(page: Page, file: string) {
|
||||
await installReviewPaneProbe(page, { file })
|
||||
await startReviewPaneProbe(page)
|
||||
await page.getByRole("button", { name: "Next file" }).click()
|
||||
return collectReviewPaneProbe(page)
|
||||
}
|
||||
|
||||
async function installReviewPaneProbe(page: Page, input: { file: string }) {
|
||||
await page.evaluate((input) => {
|
||||
const samples: ReviewPaneSample[] = []
|
||||
const basename = input.file.split(/[\\/]/).at(-1) ?? input.file
|
||||
let started: number | undefined
|
||||
let running = true
|
||||
|
||||
const paneState = () => {
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const review = panel?.querySelector<HTMLElement>('[data-component="session-review-v2"]')
|
||||
const rect = (review ?? panel)?.getBoundingClientRect()
|
||||
const text = panel?.textContent ?? ""
|
||||
const previewHeader = panel?.querySelector<HTMLElement>(
|
||||
'[data-slot="session-review-v2-file-header"]',
|
||||
)?.textContent
|
||||
const header = previewHeader ?? text
|
||||
const viewers = panel ? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')] : []
|
||||
const codeBlocks = panel?.querySelectorAll("code").length ?? 0
|
||||
const diffLines = viewers.reduce(
|
||||
(sum, viewer) =>
|
||||
sum +
|
||||
(viewer.shadowRoot?.querySelectorAll("[data-line]").length ?? viewer.querySelectorAll("[data-line]").length),
|
||||
0,
|
||||
)
|
||||
const panelVisible =
|
||||
!!panel && panel.getAttribute("aria-hidden") !== "true" && !!rect && rect.width > 0 && rect.height > 0
|
||||
return {
|
||||
panelVisible,
|
||||
header: header.slice(0, 500),
|
||||
diffViewers: viewers.length,
|
||||
diffLines,
|
||||
codeBlocks,
|
||||
ready:
|
||||
panelVisible &&
|
||||
header.includes(basename) &&
|
||||
(viewers.length > 0 || text.includes("+3") || diffLines > 0 || codeBlocks > 0),
|
||||
}
|
||||
}
|
||||
|
||||
const sample = () => {
|
||||
if (!running || started === undefined) return
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => {
|
||||
if (!running || started === undefined) return
|
||||
samples.push({ observedAtMs: performance.now() - started, ...paneState() })
|
||||
if (performance.now() - started < 10_000) sample()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe = {
|
||||
samples,
|
||||
start: () => {
|
||||
started = performance.now()
|
||||
performance.mark("opencode.review-pane.click")
|
||||
sample()
|
||||
},
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
}, input)
|
||||
}
|
||||
|
||||
async function startReviewPaneProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!.start()
|
||||
})
|
||||
}
|
||||
|
||||
async function collectReviewPaneProbe(page: Page) {
|
||||
await page.waitForFunction((streak) => {
|
||||
const samples = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe?.samples
|
||||
if (!samples) return false
|
||||
return samples.some((_, index) => {
|
||||
const stable = samples.slice(index, index + streak)
|
||||
return stable.length === streak && stable.every((sample) => sample.ready)
|
||||
})
|
||||
}, reviewReadyStreak)
|
||||
|
||||
const samples = await page.evaluate(() => {
|
||||
const probe = (window as Window & { __reviewPaneProbe?: ReviewPaneProbe }).__reviewPaneProbe!
|
||||
probe.stop()
|
||||
return probe.samples
|
||||
})
|
||||
return { summary: summarizeReviewPaneSamples(samples), samples }
|
||||
}
|
||||
|
||||
function summarizeReviewPaneSamples(samples: ReviewPaneSample[]) {
|
||||
const firstReady = samples.find((sample) => sample.ready)
|
||||
const stableIndex = samples.findIndex((_, index) => {
|
||||
const stable = samples.slice(index, index + reviewReadyStreak)
|
||||
return stable.length === reviewReadyStreak && stable.every((sample) => sample.ready)
|
||||
})
|
||||
return {
|
||||
samples: samples.length,
|
||||
firstReadyObservedMs: firstReady?.observedAtMs ?? null,
|
||||
stableReadyObservedMs: stableIndex === -1 ? null : samples[stableIndex + reviewReadyStreak - 1]!.observedAtMs,
|
||||
notReadySamples: samples.filter((sample) => !sample.ready).length,
|
||||
maxDiffViewers: Math.max(0, ...samples.map((sample) => sample.diffViewers)),
|
||||
maxDiffLines: Math.max(0, ...samples.map((sample) => sample.diffLines)),
|
||||
maxCodeBlocks: Math.max(0, ...samples.map((sample) => sample.codeBlocks)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ export async function installTimelineSettings(page: Page) {
|
||||
|
||||
export function mockStressTimeline(
|
||||
page: Page,
|
||||
input?: { onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void },
|
||||
input?: {
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
vcsDiff?: unknown[]
|
||||
},
|
||||
) {
|
||||
return mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
@@ -30,6 +33,7 @@ export function mockStressTimeline(
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
onMessages: input?.onMessages,
|
||||
vcsDiff: input?.vcsDiff,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,7 +49,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
...sessionIDs.map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -78,3 +82,53 @@ export function stressDraftHref(draftID: string) {
|
||||
function stressServer() {
|
||||
return `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
|
||||
export function createReviewDiffs() {
|
||||
return Array.from({ length: Number(process.env.REVIEW_PANE_DIFF_COUNT ?? 72) }, (_, index) => {
|
||||
const lines = index % 3 === 0 ? 300 : index % 3 === 1 ? 120 : 38
|
||||
const file = `src/review/generated-${String(index).padStart(3, "0")}.ts`
|
||||
const before = reviewSource(index, lines)
|
||||
const after = before
|
||||
.replace(`value_${index}_4`, `updated_${index}_4`)
|
||||
.replace(
|
||||
`value_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
||||
`updated_${index}_${Math.max(8, Math.floor(lines / 2))}`,
|
||||
)
|
||||
.replace(`value_${index}_${lines - 4}`, `updated_${index}_${lines - 4}`)
|
||||
return {
|
||||
file,
|
||||
patch: reviewPatch(file, before, after),
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified" as const,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function reviewSource(seed: number, lines: number) {
|
||||
return Array.from(
|
||||
{ length: lines },
|
||||
(_, index) => `export const value_${seed}_${index} = "${reviewWords(seed + index, index % 5 === 0 ? 180 : 42)}"`,
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function reviewPatch(file: string, before: string, after: string) {
|
||||
const beforeLines = before.split("\n")
|
||||
const afterLines = after.split("\n")
|
||||
return [
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
`@@ -1,${beforeLines.length} +1,${afterLines.length} @@`,
|
||||
...beforeLines.flatMap((line, index) => {
|
||||
const next = afterLines[index]!
|
||||
if (line === next) return [` ${line}`]
|
||||
return [`-${line}`, `+${next}`]
|
||||
}),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function reviewWords(seed: number, length: number) {
|
||||
const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet"]
|
||||
return Array.from({ length: Math.ceil(length / 7) }, (_, index) => words[(seed + index * 3) % words.length]).join(" ")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
analyzeVisualStability,
|
||||
analyzeVisualStabilityByMarker,
|
||||
type VisualStabilityTrace,
|
||||
} from "../../utils/visual-stability"
|
||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||
|
||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||
return { markers: [], samples }
|
||||
}
|
||||
|
||||
test("accepts continuous visible motion", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
|
||||
frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports repeated geometry reversals", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ width: 80 })),
|
||||
frame(16, region({ width: 60 })),
|
||||
frame(32, region({ width: 78 })),
|
||||
frame(48, region({ width: 62 })),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports visible blanking, label reversal, and overlap", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports duplicate regions and unexpected remounts", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
|
||||
{ stable: ["changing"], unique: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports bottom anchor loss but permits movement while scrolled away", () => {
|
||||
const anchored = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(24) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
const away = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(80) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(104) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
|
||||
expect(away).toEqual([])
|
||||
})
|
||||
|
||||
test("reports up down up movement while preserving a bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
|
||||
{ at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
|
||||
expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
|
||||
})
|
||||
|
||||
test("accepts monotonic upward movement while preserving a bottom anchor", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
|
||||
{ at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
|
||||
{ at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
|
||||
]),
|
||||
{ preserveBottomAnchor: true, maxPositionReversals: 0 },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores overlap entirely outside the clipped timeline viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: {
|
||||
changing: region({ top: -200, bottom: -100 }),
|
||||
following: region({ top: -150, bottom: -50 }),
|
||||
},
|
||||
viewport: viewport(0),
|
||||
},
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
|
||||
{ at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
|
||||
expect(issues).toEqual([])
|
||||
|
||||
const moved = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
|
||||
{ at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
|
||||
]),
|
||||
{ fixed: ["anchor"] },
|
||||
)
|
||||
expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
|
||||
})
|
||||
|
||||
test("analyzes each marked event independently", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "grow" },
|
||||
{ at: 40, label: "shrink" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
expect(
|
||||
analyzeVisualStabilityByMarker(input, {
|
||||
maxPositionReversals: 0,
|
||||
motion: ["changing"],
|
||||
aggregateMotion: false,
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports cross-event motion reversals by default", () => {
|
||||
const input: VisualStabilityTrace = {
|
||||
markers: [
|
||||
{ at: 10, label: "up" },
|
||||
{ at: 40, label: "down" },
|
||||
],
|
||||
samples: [
|
||||
frame(0, region({ top: 100 })),
|
||||
frame(16, region({ top: 90 })),
|
||||
frame(32, region({ top: 80 })),
|
||||
frame(48, region({ top: 90 })),
|
||||
frame(64, region({ top: 100 })),
|
||||
],
|
||||
}
|
||||
|
||||
expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
|
||||
})
|
||||
|
||||
test("reports regions rendered in the wrong flow order", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
|
||||
{ flow: ["changing", "following"] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
|
||||
})
|
||||
|
||||
test("uses painted bounds instead of clipped layout overflow", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(
|
||||
0,
|
||||
region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
|
||||
region({ top: 140, bottom: 180 }),
|
||||
),
|
||||
]),
|
||||
{ flow: ["changing", "following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("does not report disappearance when a present row moves outside the viewport", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ visible: true })),
|
||||
frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
|
||||
frame(32, region({ visible: true })),
|
||||
]),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an in-viewport transparent frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, opacity: 0, inViewport: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports an in-viewport display-none frame between visible frames", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region()),
|
||||
frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
|
||||
frame(32, region()),
|
||||
]),
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
|
||||
})
|
||||
|
||||
test("can limit motion analysis to unaffected regions", () => {
|
||||
expect(
|
||||
analyzeVisualStability(
|
||||
trace([
|
||||
frame(0, region({ height: 20 }), region()),
|
||||
frame(16, region({ height: 40 }), region()),
|
||||
frame(32, region({ height: 30 }), region()),
|
||||
]),
|
||||
{ motion: ["following"] },
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("reports a blank frame across replacement surfaces", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
{
|
||||
markers: [],
|
||||
samples: [
|
||||
{ at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
|
||||
{
|
||||
at: 16,
|
||||
regions: {
|
||||
thinking: region({ present: false, visible: false }),
|
||||
error: region({ present: false, visible: false }),
|
||||
},
|
||||
},
|
||||
{ at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
|
||||
],
|
||||
},
|
||||
{ continuousAny: [["thinking", "error"]] },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports failure to acquire the bottom anchor", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{ at: 0, regions: { changing: region() }, viewport: viewport(600) },
|
||||
{ at: 16, regions: { changing: region() }, viewport: viewport(120) },
|
||||
]),
|
||||
{ acquireBottomAnchor: true },
|
||||
)
|
||||
|
||||
expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
|
||||
})
|
||||
|
||||
test("reports a required region that never renders", () => {
|
||||
const issues = analyzeVisualStability(
|
||||
trace([
|
||||
{
|
||||
at: 0,
|
||||
regions: { changing: region({ present: false, visible: false }) },
|
||||
},
|
||||
]),
|
||||
{ required: ["changing"] },
|
||||
)
|
||||
|
||||
expect(issues).toContain("changing never rendered")
|
||||
})
|
||||
|
||||
test("preserves typed region names while mapping definitions", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]", closest: "[data-row]" },
|
||||
})
|
||||
const selectors = mapVisualRegions(regions, (region) => region.selector)
|
||||
|
||||
expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
|
||||
const name: keyof typeof selectors = "changing"
|
||||
expect(name).toBe("changing")
|
||||
})
|
||||
|
||||
test("evaluates the typed invariant algebra over explicit observations", () => {
|
||||
const regions = defineVisualRegions({
|
||||
changing: { selector: "[data-changing]" },
|
||||
following: { selector: "[data-following]" },
|
||||
})
|
||||
const invariants = [
|
||||
{ type: "required", regions: ["changing"] },
|
||||
{ type: "flow", regions: ["changing", "following"] },
|
||||
] satisfies VisualInvariant<keyof typeof regions>[]
|
||||
// @ts-expect-error Plans reject names that are not in the region definition.
|
||||
const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
|
||||
const plan = visualPlan(regions, invariants, { perMarker: true })
|
||||
|
||||
expect(invalid.regions).toEqual(["missing"])
|
||||
expect(plan.perMarker).toBe(true)
|
||||
expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
|
||||
"changing overlapped following by 1px at 0ms",
|
||||
])
|
||||
})
|
||||
|
||||
test("legacy plan adapter preserves analyzer messages and order", () => {
|
||||
const input = trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
])
|
||||
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
||||
|
||||
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
||||
analyzeVisualStability(input, options),
|
||||
)
|
||||
})
|
||||
|
||||
function frame(
|
||||
at: number,
|
||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
following?: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
) {
|
||||
return { at, regions: { changing, ...(following ? { following } : {}) } }
|
||||
}
|
||||
|
||||
function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
|
||||
return {
|
||||
present: true,
|
||||
visible: true,
|
||||
inViewport: true,
|
||||
top: 0,
|
||||
bottom: 20,
|
||||
width: 100,
|
||||
height: 20,
|
||||
opacity: 1,
|
||||
count: 1,
|
||||
node: 1,
|
||||
label: "",
|
||||
text: "",
|
||||
layoutTop: input.top ?? 0,
|
||||
layoutBottom: input.bottom ?? 20,
|
||||
...input,
|
||||
}
|
||||
}
|
||||
|
||||
function viewport(distanceFromBottom: number) {
|
||||
return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
||||
const high = page.getByRole("option", { name: "high" })
|
||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||
await expect(high).toBeVisible()
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(control).toBeVisible()
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
const serverA = "http://127.0.0.1:4096"
|
||||
const serverB = "http://127.0.0.1:4097"
|
||||
const sessionA = session("ses_server_a", "C:/server-a", "Server A session")
|
||||
const sessionB = session("ses_server_b", "/home/server-b", "Server B session")
|
||||
|
||||
test("tab busy indicator reflects the tab server's own session status", async ({ page }) => {
|
||||
await mockServers(page)
|
||||
await page.addInitScript(
|
||||
({ serverA, serverB, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server: serverA, sessionId: sessionA },
|
||||
{ type: "session", server: serverB, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ serverA, serverB, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(serverA)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
// Session B is busy on server B while server A stays the active server, so the
|
||||
// busy indicator must come from the tab server's status, not the active server's.
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
|
||||
await expect(tabB.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
|
||||
await expect(tabA.locator("[data-titlebar-tab-title]")).toHaveText(sessionA.title)
|
||||
await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: `project-${id}`,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServers(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session/status")
|
||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
||||
if (url.pathname === "/session") return json(route, [current])
|
||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
||||
const sessionID = "ses_review_image_flash_regression"
|
||||
const title = "Review image flash regression"
|
||||
const imageFile = "assets/preview.png"
|
||||
|
||||
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
||||
await openReview(page)
|
||||
await installReviewFlashProbe(page)
|
||||
|
||||
await page.getByRole("button", { name: /preview\.png/ }).click()
|
||||
await waitForReviewFlashProbe(page, 400)
|
||||
const trace = await collectReviewFlashProbe(page)
|
||||
const bad = trace.samples.filter((sample) => sample.blank || sample.blackCenter)
|
||||
|
||||
expect(trace.samples.length).toBeGreaterThan(0)
|
||||
expect(
|
||||
bad,
|
||||
JSON.stringify({ bad: bad.slice(0, 8), first: trace.samples.slice(0, 8), last: trace.samples.slice(-4) }, null, 2),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 960, height: 900 })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_image_flash_regression",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-image-flash-regression",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-image-flash-regression",
|
||||
projectID: "proj_review_image_flash_regression",
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
{
|
||||
file: imageFile,
|
||||
patch: "",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
},
|
||||
],
|
||||
fileContent: async (path) => {
|
||||
if (path !== imageFile) return undefined
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
return {
|
||||
type: "binary",
|
||||
content: "iVBORw0KGgo=",
|
||||
encoding: "base64",
|
||||
mimeType: "image/png",
|
||||
}
|
||||
},
|
||||
fileList: (path) => {
|
||||
if (!path) {
|
||||
return [
|
||||
{ name: "assets", path: "assets", absolute: `${directory}/assets`, type: "directory", ignored: false },
|
||||
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||
]
|
||||
}
|
||||
if (path === "assets") {
|
||||
return [
|
||||
{
|
||||
name: "preview.png",
|
||||
path: imageFile,
|
||||
absolute: `${directory}/${imageFile}`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (path === "src") {
|
||||
return [
|
||||
{
|
||||
name: "example.ts",
|
||||
path: "src/example.ts",
|
||||
absolute: `${directory}/src/example.ts`,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
},
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_image_flash_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_image_flash_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_image_flash_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
||||
await expectAppVisible(page.getByRole("button", { name: /preview\.png/ }))
|
||||
}
|
||||
|
||||
async function installReviewFlashProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const samples: Array<{
|
||||
observedAtMs: number
|
||||
blank: boolean
|
||||
blackCenter: boolean
|
||||
text: string
|
||||
background: string
|
||||
}> = []
|
||||
const startedAt = performance.now()
|
||||
const sample = () => {
|
||||
const panel = document.querySelector<HTMLElement>('#review-panel [data-component="session-review-v2"]')
|
||||
const rect = panel?.getBoundingClientRect()
|
||||
const center = rect
|
||||
? document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)
|
||||
: undefined
|
||||
const background = center instanceof Element ? getComputedStyle(center).backgroundColor : ""
|
||||
samples.push({
|
||||
observedAtMs: performance.now() - startedAt,
|
||||
blank: !panel || panel.textContent?.trim().length === 0,
|
||||
blackCenter: background === "rgb(0, 0, 0)",
|
||||
text: panel?.textContent?.trim().slice(0, 80) ?? "",
|
||||
background,
|
||||
})
|
||||
if (performance.now() - startedAt < 500) requestAnimationFrame(sample)
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target instanceof Element ? event.target : undefined
|
||||
if (!target?.closest('[data-slot="file-tree-v2-row"]')) return
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __reviewImageFlash?: { samples: typeof samples; startedAt: number } }).__reviewImageFlash = {
|
||||
samples,
|
||||
startedAt,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForReviewFlashProbe(page: Page, durationMs: number) {
|
||||
await page.waitForFunction((durationMs) => {
|
||||
const state = (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } })
|
||||
.__reviewImageFlash
|
||||
return !!state && state.samples.length > 0 && performance.now() - state.startedAt >= durationMs
|
||||
}, durationMs)
|
||||
}
|
||||
|
||||
async function collectReviewFlashProbe(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
return (window as Window & { __reviewImageFlash?: { samples: unknown[]; startedAt: number } }).__reviewImageFlash!
|
||||
}) as Promise<{
|
||||
startedAt: number
|
||||
samples: Array<{ observedAtMs: number; blank: boolean; blackCenter: boolean; text: string; background: string }>
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTabSwitch"
|
||||
const projectID = "proj_review_tab_switch"
|
||||
const sessionA = "ses_review_tab_a"
|
||||
const sessionB = "ses_review_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// The v2 review pane's diff data is workspace-scoped: switching between session
|
||||
// tabs in the same workspace must update its parameters reactively instead of
|
||||
// tearing the pane down and remounting it (which flickers).
|
||||
test("keeps the v2 review pane mounted when switching session tabs in a workspace", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function switchTab(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
}
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page.locator('#review-panel [data-component="session-review-v2"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-tab-switch",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
|
||||
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
|
||||
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByText("Select one answer")).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
|
||||
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
|
||||
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
|
||||
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
|
||||
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
await expect(question).toBeVisible()
|
||||
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
|
||||
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
|
||||
expect(rejectRequests).toEqual([])
|
||||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, shell, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("space activates a focused timeline button instead of scrolling", async ({ page }) => {
|
||||
const shellID = "prt_space_button_shell"
|
||||
await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", lines(5))])],
|
||||
settings: { shellToolPartsExpanded: false },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await trigger.focus()
|
||||
const before = await scroller.evaluate((element) => element.scrollTop)
|
||||
await trigger.press("Space")
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
expect(await scroller.evaluate((element) => element.scrollTop)).toBe(before)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
analyzeVisualObservations,
|
||||
defineVisualRegions,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../utils/visual-stability"
|
||||
|
||||
const directory = "C:/OpenCode/ContextResizeRegression"
|
||||
const projectID = "proj_context_resize_regression"
|
||||
@@ -36,6 +43,82 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
expect(visibleOverlap).toEqual([])
|
||||
expect(samples.at(-1)?.expanded).toBe("true")
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||
...turn(10, true, "running"),
|
||||
])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Exploring")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
status: {
|
||||
selector: `${contextSelector} [data-component="tool-status-title"]`,
|
||||
opacitySelectors: ['[data-slot="tool-status-active"]', '[data-slot="tool-status-done"]'],
|
||||
},
|
||||
context: { selector: contextSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingTextID}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", "Explored")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
.map((sample) => sample.regions.status?.label)
|
||||
.filter((value): value is string => !!value)
|
||||
.filter((value, index, all) => value !== all[index - 1])
|
||||
const issues = analyzeVisualObservations(
|
||||
trace.samples,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all" },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["Exploring", "Explored"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
@@ -128,7 +211,7 @@ async function sampleExpansion(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean): Message[] {
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
return [
|
||||
@@ -163,10 +246,22 @@ function turn(index: number, target: boolean): Message[] {
|
||||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
|
||||
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
@@ -188,7 +283,13 @@ function turn(index: number, target: boolean): Message[] {
|
||||
]
|
||||
}
|
||||
|
||||
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
|
||||
function contextTool(
|
||||
partID: string,
|
||||
messageID: string,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
status: "running" | "completed" = "completed",
|
||||
) {
|
||||
return {
|
||||
id: partID,
|
||||
sessionID,
|
||||
@@ -197,7 +298,7 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
||||
callID: `call_${partID}`,
|
||||
tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
status,
|
||||
input,
|
||||
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
|
||||
title: input.filePath || input.path || input.pattern || "completed",
|
||||
@@ -207,13 +308,19 @@ function contextTool(partID: string, messageID: string, tool: string, input: Rec
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
||||
fixtureMessages = messages,
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions: [session()],
|
||||
pageMessages: () => ({ items: messages }),
|
||||
pageMessages: () => ({ items: fixtureMessages }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 50,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("preserves a collapsed context group through count and status updates", async ({ page }) => {
|
||||
const ids = ["prt_closed_01_read", "prt_closed_02_glob"]
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[toolPart(ids[0]!, "read", "running", inputs.read), toolPart(ids[1]!, "glob", "running", inputs.glob)],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const trigger = group.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(toolPart(ids[0]!, "read", "completed", inputs.read)), 100)
|
||||
await timeline.send(partUpdated(toolPart(ids[1]!, "glob", "completed", inputs.glob)), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders completed write content", async ({ page }) => {
|
||||
const id = "prt_file_projection_write"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(id, "write", "completed", { filePath: "src/write.ts", content: "export const written = true\n" }),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="write-content"]`)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders a completed single-file patch", async ({ page }) => {
|
||||
const id = "prt_file_projection_single_patch"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
filePath: "src/a.ts",
|
||||
relativePath: "src/a.ts",
|
||||
type: "update",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"] [data-component="apply-patch-file-diff"]`)).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
||||
const editID = "prt_diagnostics_edit"
|
||||
const base = editPart(editID, [])
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([base])],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(
|
||||
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
const files = [patchFile("src/a.ts", "update"), patchFile("src/b.ts", "add"), patchFile("src/old.ts", "delete")]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(
|
||||
patchID,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: files.map((file) => file.filePath) },
|
||||
{ metadata: { files } },
|
||||
),
|
||||
]),
|
||||
],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||
const outer = wrapper.locator('[data-slot="collapsible-trigger"]').first()
|
||||
const deleted = wrapper.locator('[data-scope="apply-patch"] [data-type="delete"]')
|
||||
await deleted.getByRole("button").click()
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "false")
|
||||
await outer.click()
|
||||
await expect(outer).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted.getByRole("button")).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 4,
|
||||
deletions: type === "add" ? 0 : 3,
|
||||
before: type === "add" ? undefined : source(false),
|
||||
after: type === "delete" ? undefined : source(true),
|
||||
}
|
||||
}
|
||||
|
||||
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
||||
diagnostics,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function diagnostic(message: string, line: number) {
|
||||
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const expanded of [false, true]) {
|
||||
test(`preserves shell user intent from a ${expanded ? "expanded" : "collapsed"} default`, async ({ page }) => {
|
||||
const id = `prt_shell_default_${expanded}`
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([shell(id, "completed", lines(3))])],
|
||||
settings: { shellToolPartsExpanded: expanded },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${id}"] [data-slot="collapsible-trigger"]`)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
|
||||
await timeline.send(partUpdated(shell(id, "completed", lines(6))), 180)
|
||||
await timeline.send(partUpdated(textPart(`prt_sibling_${expanded}`, "Sibling content")), 180)
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
})
|
||||
}
|
||||
|
||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||
const reasoningID = "prt_reasoning_hidden"
|
||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistant],
|
||||
settings: { showReasoningSummaries: false },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("moves busy through retry and recovery to final idle content", async ({ page }) => {
|
||||
const assistant = assistantMessage([], { completed: false })
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: [
|
||||
{
|
||||
file: "src/retry.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
assistant,
|
||||
],
|
||||
})
|
||||
await timeline.send(status("busy"), 140)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
await timeline.send(status("retry"), 180)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const profile of [
|
||||
{ locale: "de", label: "Erkundet" },
|
||||
{ locale: "ar", label: "تم الاستكشاف" },
|
||||
] as const) {
|
||||
test(`projects translated context status in ${profile.locale}`, async ({ page }) => {
|
||||
const ids = [`prt_locale_${profile.locale}_01_read`, `prt_locale_${profile.locale}_02_glob`]
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart(ids[1]!, "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
]),
|
||||
],
|
||||
locale: profile.locale,
|
||||
})
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
await expect(group.locator('[data-component="tool-status-title"]')).toHaveAttribute("aria-label", profile.label)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", profile.locale)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
setupTimeline,
|
||||
status,
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
type PartSeed,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test.describe("session timeline projection", () => {
|
||||
test("renders every admitted tool family and hides timeline-only exclusions", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
toolPart("prt_02_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_03_grep", "grep", "completed", { path: ".", pattern: "value" }),
|
||||
toolPart("prt_04_list", "list", "completed", { path: "src" }),
|
||||
toolPart("prt_webfetch", "webfetch", "completed", { url: "https://example.com" }),
|
||||
toolPart(
|
||||
"prt_websearch",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "timeline stability" },
|
||||
{ output: "https://example.com/result" },
|
||||
),
|
||||
toolPart("prt_task", "task", "completed", { description: "Inspect timeline", subagent_type: "explore" }),
|
||||
toolPart(
|
||||
"prt_bash",
|
||||
"bash",
|
||||
"completed",
|
||||
{ command: "printf stable" },
|
||||
{ output: "stable", title: "printf stable" },
|
||||
),
|
||||
editPart("prt_edit"),
|
||||
toolPart("prt_write", "write", "completed", { filePath: "src/new.ts", content: "export const stable = true\n" }),
|
||||
patchPart("prt_patch"),
|
||||
toolPart("prt_todo", "todowrite", "completed", { todos: [{ content: "Hidden", status: "pending" }] }),
|
||||
toolPart(
|
||||
"prt_question",
|
||||
"question",
|
||||
"completed",
|
||||
{ questions: [{ question: "Keep stable?", header: "Stability", options: [] }] },
|
||||
{ metadata: { answers: [["Yes"]] } },
|
||||
),
|
||||
toolPart("prt_skill", "skill", "completed", { name: "stability" }),
|
||||
toolPart("prt_custom", "custom_mcp_tool", "completed", { target: "timeline", count: 2 }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids="prt_01_read,prt_02_glob,prt_03_grep,prt_04_list"]'),
|
||||
).toBeVisible()
|
||||
for (const id of [
|
||||
"prt_webfetch",
|
||||
"prt_websearch",
|
||||
"prt_task",
|
||||
"prt_bash",
|
||||
"prt_edit",
|
||||
"prt_write",
|
||||
"prt_patch",
|
||||
"prt_question",
|
||||
"prt_skill",
|
||||
"prt_custom",
|
||||
]) {
|
||||
await expect(page.locator(`[data-timeline-part-id="${id}"]`).first(), id).toBeVisible()
|
||||
}
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const aborted = assistantMessage(
|
||||
[
|
||||
{ id: "prt_before_abort", type: "text", text: "Before interruption" },
|
||||
{ id: "prt_compaction", type: "compaction", auto: true },
|
||||
],
|
||||
{
|
||||
id: "msg_1001_assistant_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
)
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
||||
isRetryable: false,
|
||||
},
|
||||
},
|
||||
created: 1700000003000,
|
||||
})
|
||||
const nextUser = userMessage([userText("Second turn", { id: "prt_second_user" })], {
|
||||
id: "msg_2000_second_user",
|
||||
created: 1700000005000,
|
||||
})
|
||||
const nextAssistant = assistantMessage([{ id: "prt_second_text", type: "text", text: "Second response" }], {
|
||||
id: "msg_2001_second_assistant",
|
||||
parentID: "msg_2000_second_user",
|
||||
created: 1700000006000,
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [firstUser, aborted, failed, nextUser, nextAssistant] })
|
||||
await timeline.send(status("idle"), 100)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
|
||||
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Visible provider failure")).toBeVisible()
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders comment strips and historical diff summary overflow", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment_only",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
||||
const nextAssistant = assistantMessage([], {
|
||||
id: "msg_2001_diff_next_assistant",
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible()
|
||||
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||
await expect(page.getByText(/show all/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
created: 1700000003000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, before, after] })
|
||||
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
const rows = await page
|
||||
.locator('[data-timeline-row="AssistantPart"], [data-timeline-row="TurnDivider"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-timeline-row")))
|
||||
expect(rows).toEqual(["AssistantPart", "TurnDivider", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("renders user image, file attachment, file reference, and agent reference", async ({ page }) => {
|
||||
const text = "Use @explore with @src/a.ts and inspect the attachments"
|
||||
const parts: PartSeed<"user">[] = [
|
||||
userText(text, { id: "prt_user_rich" }),
|
||||
{
|
||||
id: "prt_user_image",
|
||||
type: "file",
|
||||
mime: "image/png",
|
||||
filename: "pixel.png",
|
||||
url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
},
|
||||
{
|
||||
id: "prt_user_attachment",
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "tsconfig.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
id: "prt_user_reference",
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", path: "src/a.ts", text: { value: "@src/a.ts", start: 18, end: 27 } },
|
||||
},
|
||||
{
|
||||
id: "prt_user_agent",
|
||||
type: "agent",
|
||||
name: "explore",
|
||||
source: { value: "@explore", start: 4, end: 12 },
|
||||
},
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(parts), assistantMessage()] })
|
||||
|
||||
await expect(page.getByAltText("pixel.png")).toBeVisible()
|
||||
await expect(page.getByText("tsconfig.json")).toBeVisible()
|
||||
await expect(page.getByText("@src/a.ts", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("@explore", { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
function editPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/a.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: {
|
||||
file: "src/a.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
before: "export const value = 1\n",
|
||||
after: "export const value = 2\n",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchPart(id: string) {
|
||||
return toolPart(
|
||||
id,
|
||||
"apply_patch",
|
||||
"completed",
|
||||
{ files: ["src/a.ts", "src/b.ts"] },
|
||||
{
|
||||
metadata: {
|
||||
files: [
|
||||
patchFile("src/a.ts", "update"),
|
||||
patchFile("src/b.ts", "add"),
|
||||
patchFile("src/old.ts", "delete"),
|
||||
{ ...patchFile("src/moved.ts", "move"), move: "src/new-place.ts" },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function patchFile(filePath: string, type: "add" | "update" | "delete" | "move") {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath,
|
||||
type,
|
||||
additions: type === "delete" ? 0 : 1,
|
||||
deletions: type === "add" ? 0 : 1,
|
||||
before: type === "add" ? undefined : "export const before = true\n",
|
||||
after: type === "delete" ? undefined : "export const after = true\n",
|
||||
}
|
||||
}
|
||||
|
||||
function summaryDiff(index: number) {
|
||||
return {
|
||||
file: `src/diff-${index}.ts`,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
reasoningPart,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
const profiles = [
|
||||
{ name: "summaries off no reasoning", summaries: false, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries off reasoning heading",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries off with visible tool",
|
||||
summaries: false,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: true,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{ name: "summaries on no content", summaries: true, reasoning: "", other: false, thinking: true, body: false },
|
||||
{
|
||||
name: "summaries on blank reasoning",
|
||||
summaries: true,
|
||||
reasoning: " ",
|
||||
other: false,
|
||||
thinking: true,
|
||||
body: false,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible reasoning",
|
||||
summaries: true,
|
||||
reasoning: "## Inspecting stability",
|
||||
other: false,
|
||||
thinking: false,
|
||||
body: true,
|
||||
},
|
||||
{
|
||||
name: "summaries on visible tool no reasoning",
|
||||
summaries: true,
|
||||
reasoning: "",
|
||||
other: true,
|
||||
thinking: false,
|
||||
body: false,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const profile of profiles) {
|
||||
test(`projects busy reasoning profile ${profile.name}`, async ({ page }) => {
|
||||
const reasoningID = `prt_reasoning_matrix_${profiles.indexOf(profile)}`
|
||||
const parts = [
|
||||
...(profile.reasoning ? [reasoningPart(reasoningID, profile.reasoning)] : []),
|
||||
...(profile.other
|
||||
? [toolPart(`prt_reasoning_tool_${profiles.indexOf(profile)}`, "skill", "running", { name: "inspect" })]
|
||||
: []),
|
||||
]
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage(parts, { completed: false })],
|
||||
settings: { showReasoningSummaries: profile.summaries },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0)
|
||||
if (!profile.summaries && profile.reasoning.trim()) {
|
||||
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test("does not infer reasoning visibility from provider identity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart("prt_provider_text", "No reasoning payload")], { completed: false }),
|
||||
],
|
||||
settings: { showReasoningSummaries: true },
|
||||
})
|
||||
await timeline.send(status("busy"), 150)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("groups singleton and separated context operations at correct boundaries", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart("prt_boundary_01_read", "read", "completed", { filePath: "src/a.ts" }),
|
||||
textPart("prt_boundary_02_text", "Boundary text"),
|
||||
toolPart("prt_boundary_03_glob", "glob", "completed", { path: ".", pattern: "**/*.ts" }),
|
||||
toolPart("prt_boundary_04_grep", "grep", "completed", { path: ".", pattern: "stable" }),
|
||||
shell("prt_boundary_05_shell", "completed", "done"),
|
||||
toolPart("prt_boundary_06_list", "list", "completed", { path: "src" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
|
||||
})
|
||||
|
||||
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
|
||||
const textID = "prt_event_order_text"
|
||||
const assistant = assistantMessage([textPart(textID, "Partial")], { completed: false })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] })
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("renders every tool error outcome without leaking hidden tools", async ({ page }) => {
|
||||
const ordinary = ["bash", "edit", "write", "apply_patch", "webfetch", "websearch", "task", "skill", "mcp_probe"]
|
||||
const parts = ordinary.map((tool, index) =>
|
||||
toolPart(`prt_error_${index}`, tool, "error", errorInput(tool), { error: `${tool} failed visibly` }),
|
||||
)
|
||||
parts.push(
|
||||
toolPart("prt_question_dismissed", "question", "error", questionInput(), {
|
||||
error: "The user dismissed this question",
|
||||
}),
|
||||
toolPart("prt_question_error", "question", "error", questionInput(), { error: "Question transport failed" }),
|
||||
toolPart("prt_todo_error", "todowrite", "error", { todos: [] }, { error: "Hidden todo failure" }),
|
||||
)
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.locator('[data-kind="tool-error-card"]')).toHaveCount(ordinary.length + 1)
|
||||
await expect(page.getByText(/dismissed/i)).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-id="prt_todo_error"]')).toHaveCount(0)
|
||||
for (let index = 0; index < ordinary.length; index++) {
|
||||
await expect(page.locator(`[data-timeline-part-id="prt_error_${index}"]`)).toBeVisible()
|
||||
}
|
||||
})
|
||||
|
||||
test("transitions shell and question through running error outcomes", async ({ page }) => {
|
||||
const shellID = "prt_transition_error_shell"
|
||||
const questionID = "prt_transition_error_question"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(shellID, "bash", "pending", { command: "exit 1" }),
|
||||
toolPart(questionID, "question", "pending", questionInput()),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
})
|
||||
await timeline.waitForPart(shellID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(toolPart(shellID, "bash", "running", { command: "exit 1" })), 120)
|
||||
await timeline.send(partUpdated(toolPart(questionID, "question", "running", questionInput())), 180)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(shellID, "bash", "error", { command: "exit 1" }, { error: "Command exited 1" })),
|
||||
180,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "error", questionInput(), { error: "The user dismissed this question" }),
|
||||
),
|
||||
250,
|
||||
)
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${shellID}"] [data-kind="tool-error-card"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
|
||||
})
|
||||
|
||||
test("labels all web search provider variants", async ({ page }) => {
|
||||
const parts = [
|
||||
toolPart(
|
||||
"prt_search_parallel",
|
||||
"websearch",
|
||||
"completed",
|
||||
{ query: "parallel" },
|
||||
{ metadata: { provider: "parallel" } },
|
||||
),
|
||||
toolPart("prt_search_exa", "websearch", "completed", { query: "exa" }, { metadata: { provider: "exa" } }),
|
||||
toolPart("prt_search_generic", "websearch", "completed", { query: "generic" }),
|
||||
]
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
await expect(page.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||
})
|
||||
|
||||
function questionInput() {
|
||||
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
|
||||
}
|
||||
|
||||
function errorInput(tool: string) {
|
||||
if (tool === "bash") return { command: "exit 1" }
|
||||
if (["edit", "write"].includes(tool)) return { filePath: "src/error.ts", content: "" }
|
||||
if (tool === "apply_patch") return { files: ["src/error.ts"] }
|
||||
if (tool === "webfetch") return { url: "https://example.com" }
|
||||
if (tool === "websearch") return { query: "failure" }
|
||||
if (tool === "task") return { description: "Fail task", subagent_type: "explore" }
|
||||
if (tool === "skill") return { name: "failure" }
|
||||
return { target: "failure" }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
||||
const searchID = "prt_websearch_mutation"
|
||||
const input = { query: "timeline stability" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(searchID, "websearch", "completed", input, {
|
||||
output: "https://example.com/one\nhttps://example.com/two",
|
||||
}),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
||||
const toolID = "prt_duplicate_error"
|
||||
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(partUpdated(failed), 150)
|
||||
await timeline.send(partUpdated(failed), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Command failed visibly")
|
||||
})
|
||||
|
||||
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
||||
const questionID = "prt_multi_question"
|
||||
const input = {
|
||||
questions: [
|
||||
{ header: "First", question: "First choice?", options: [] },
|
||||
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
||||
],
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Updated")
|
||||
await expect(wrapper).toContainText("B, C")
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const acknowledgements = await timeline.transport.burst([
|
||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
})
|
||||
|
||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||
const multibyte = encoded.indexOf(snowman)
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
})
|
||||
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
|
||||
})
|
||||
const before = await page.locator("[data-timeline-row]").allTextContents()
|
||||
|
||||
await timeline.transport.heartbeat()
|
||||
await timeline.settle()
|
||||
|
||||
expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reconnects after a clean close", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.close()
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("close")
|
||||
})
|
||||
|
||||
test("reconnects after a stream error", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.waitForConnection()
|
||||
|
||||
await timeline.transport.error("contract failure")
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(status("busy"))
|
||||
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||
})
|
||||
|
||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||
|
||||
expect(first.eventID).toBe("timeline-event-7")
|
||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
||||
})
|
||||
|
||||
test("passes through non-event fetches", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const health = await page.evaluate(async () => {
|
||||
const response = await fetch("/global/health")
|
||||
return response.json()
|
||||
})
|
||||
|
||||
expect(health).toEqual({ healthy: true })
|
||||
expect(await timeline.transport.connections()).toHaveLength(1)
|
||||
})
|
||||
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/SubagentNavigation"
|
||||
const projectID = "proj_subagent_navigation"
|
||||
const parentID = "ses_subagent_parent"
|
||||
const childID = "ses_subagent_child"
|
||||
const parentTitle = "Parent session"
|
||||
const childTitle = "Subagent child session"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
|
||||
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
|
||||
|
||||
const titlebarRight = page.locator("#opencode-titlebar-right")
|
||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "subagent-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
||||
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
})
|
||||
// The child session resolves via /session/:id but is absent from the /session list,
|
||||
// matching a subagent session that has not been loaded into the list cache yet.
|
||||
await page.route(
|
||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
||||
}),
|
||||
)
|
||||
await configurePage(page)
|
||||
}
|
||||
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
await card.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function childSession() {
|
||||
return session(childID, childTitle, 1700000001000, { parentID })
|
||||
}
|
||||
|
||||
function parentMessages() {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID: parentID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text_0001",
|
||||
sessionID: parentID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID: parentID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
parentID: userID,
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "opencode",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_tool_task_0001",
|
||||
sessionID: parentID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: "call_task_0001",
|
||||
tool: "task",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: taskDescription, subagent_type: "explore" },
|
||||
output: "Subagent finished",
|
||||
title: taskDescription,
|
||||
metadata: { sessionId: childID },
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessionId }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
|
||||
},
|
||||
{ directory, server, sessionId: parentID },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
|
||||
const server = "http://127.0.0.1:4096"
|
||||
const sessionA = session("ses_tab_a", "Tab A session")
|
||||
const sessionB = session("ses_tab_b", "Tab B session")
|
||||
|
||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "session", server, sessionId: sessionA },
|
||||
{ type: "session", server, sessionId: sessionB },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ server, sessionA: sessionA.id, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
await page.goto(hrefA)
|
||||
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||
|
||||
const linkB = page.locator(`a[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(linkB).toBeVisible()
|
||||
const box = await linkB.boundingBox()
|
||||
if (!box) throw new Error("tab link has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
|
||||
// Navigation must happen on mousedown, before the button is released.
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
await page.mouse.up()
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID: "project-tabs",
|
||||
directory: "C:/tab-project",
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(page: Page) {
|
||||
const sessions = [sessionA, sessionB]
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||
if (url.pathname === "/session") return json(route, sessions)
|
||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||
if (byId) return json(route, byId)
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
||||
return json(route, {})
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: sessionA.projectID,
|
||||
worktree: sessionA.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
return json(route, {})
|
||||
})
|
||||
}
|
||||
|
||||
function json(route: Route, body: unknown, status = 200) {
|
||||
return route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TerminalTabSwitch"
|
||||
const projectID = "proj_terminal_tab_switch"
|
||||
const sessionA = "ses_terminal_tab_a"
|
||||
const sessionB = "ses_terminal_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const ptyID = "pty_tab_switch"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
// Marks the terminal DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
// Terminals are workspace-scoped: switching between session tabs in the same
|
||||
// workspace must keep the terminal mounted and its PTY connection open instead
|
||||
// of tearing it down and reconnecting.
|
||||
test("keeps the terminal session alive when switching session tabs in a workspace", async ({ page }) => {
|
||||
const connections = await setup(page)
|
||||
|
||||
await page.goto(sessionHref(sessionA))
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => connections.length).toBe(1)
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expect(terminal).toBeVisible()
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
expect(connections.length).toBe(1)
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
|
||||
async function switchTab(page: Page, title: string) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||
}
|
||||
|
||||
async function writeProbe(page: Page) {
|
||||
await page.locator('[data-component="terminal"]').evaluate((el, probe) => {
|
||||
;(el as Probed).__e2eProbe = probe
|
||||
}, PROBE)
|
||||
}
|
||||
|
||||
async function readProbe(page: Page) {
|
||||
return page.locator('[data-component="terminal"]').evaluate((el) => (el as Probed).__e2eProbe)
|
||||
}
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "terminal-tab-switch",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
const connections: string[] = []
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
||||
connections.push(ws.url())
|
||||
})
|
||||
|
||||
await page.addInitScript(
|
||||
({ directory, server, sessions }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, server, sessions: [sessionA, sessionB] },
|
||||
)
|
||||
return connections
|
||||
}
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
|
||||
await page.addInitScript(
|
||||
({ dirBase64, sourceID, targetID }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:tabs",
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(
|
||||
[sourceID, targetID].map((sessionId) => ({
|
||||
type: "session",
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"rootDir": "..",
|
||||
"types": ["node", "bun"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
"include": [
|
||||
"./performance/timeline-stability/**/*.spec.ts",
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
@@ -17,6 +17,9 @@ export interface MockServerConfig {
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
sessionStatus?: unknown
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
@@ -53,7 +56,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
url: string
|
||||
path: "/global/event" | "/event"
|
||||
headers: Record<string, string>
|
||||
openedAt: number
|
||||
endedAt?: number
|
||||
endedBy?: "close" | "disconnect" | "error" | "abort"
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SseDeliveryAcknowledgement = {
|
||||
deliveryID: number
|
||||
connectionID: number
|
||||
bytes: number
|
||||
chunkCount: number
|
||||
deliveredAt: number
|
||||
eventID?: string
|
||||
}
|
||||
|
||||
export type SseEventOptions = {
|
||||
id?: string
|
||||
event?: string
|
||||
retry?: number
|
||||
marker?: string
|
||||
}
|
||||
|
||||
export type SseTransport<T> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
|
||||
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
|
||||
close(): Promise<void>
|
||||
disconnect(message?: string): Promise<void>
|
||||
error(message?: string): Promise<void>
|
||||
connections(): Promise<SseConnectionRecord[]>
|
||||
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
|
||||
}
|
||||
|
||||
type BrowserCommand<T> =
|
||||
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
|
||||
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
|
||||
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
|
||||
| { type: "connections" }
|
||||
| { type: "acknowledgements" }
|
||||
|
||||
type BrowserTransport = Window & {
|
||||
__testSseTransport?: {
|
||||
command: (command: BrowserCommand<unknown>) => unknown
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
): Promise<SseTransport<T>> {
|
||||
const server = new URL(options.server).origin
|
||||
await page.addInitScript(
|
||||
({ server, retry }) => {
|
||||
type Connection = SseConnectionRecord & { controller: ReadableStreamDefaultController<Uint8Array> }
|
||||
type ProbeWindow = Window & {
|
||||
__visualStabilityProbe?: { startedAt: number; markers: { at: number; label: string }[] }
|
||||
}
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
const connections: Connection[] = []
|
||||
const acknowledgements: SseDeliveryAcknowledgement[] = []
|
||||
const encoder = new TextEncoder()
|
||||
let nextConnectionID = 0
|
||||
let nextDeliveryID = 0
|
||||
|
||||
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
|
||||
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
|
||||
const boundaries = [...new Set(cuts ?? [])]
|
||||
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
|
||||
.sort((a, b) => a - b)
|
||||
return [0, ...boundaries].map((start, index) => bytes.slice(start, boundaries[index] ?? bytes.byteLength))
|
||||
}
|
||||
const marker = (label?: string) => {
|
||||
if (!label) return
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}
|
||||
const frame = (payload: unknown, eventOptions: SseEventOptions = {}) =>
|
||||
[
|
||||
eventOptions.event === undefined ? "" : `event: ${eventOptions.event}\n`,
|
||||
eventOptions.id === undefined ? "" : `id: ${eventOptions.id}\n`,
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
chunkCount: number,
|
||||
eventID?: string,
|
||||
): SseDeliveryAcknowledgement => {
|
||||
const acknowledgement = {
|
||||
deliveryID: ++nextDeliveryID,
|
||||
connectionID: connection.id,
|
||||
bytes,
|
||||
chunkCount,
|
||||
deliveredAt: performance.now(),
|
||||
...(eventID === undefined ? {} : { eventID }),
|
||||
}
|
||||
acknowledgements.push(acknowledgement)
|
||||
return acknowledgement
|
||||
}
|
||||
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
connection.endedAt = performance.now()
|
||||
connection.endedBy = mode
|
||||
if (message) connection.error = message
|
||||
if (mode === "close") {
|
||||
connection.controller.close()
|
||||
return
|
||||
}
|
||||
const error = new DOMException(
|
||||
message ?? "SSE connection disconnected",
|
||||
mode === "error" ? "Error" : "NetworkError",
|
||||
)
|
||||
connection.controller.error(error)
|
||||
}
|
||||
|
||||
const command = (input: BrowserCommand<unknown>) => {
|
||||
if (input.type === "connections")
|
||||
return connections.map(({ controller: _controller, ...connection }) => connection)
|
||||
if (input.type === "acknowledgements") return acknowledgements
|
||||
if (input.type === "end") return end(input.mode, input.message)
|
||||
const connection = current()
|
||||
if (!connection) throw new Error("SSE transport has no active connection")
|
||||
if (input.type === "raw") {
|
||||
marker(input.marker)
|
||||
const output = chunks(new Uint8Array(input.bytes), input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(
|
||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
||||
)
|
||||
connection.controller.enqueue(bytes)
|
||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||
}
|
||||
const output = chunks(encoded[0]!.bytes, input.cuts)
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, encoded[0]!.bytes.byteLength, output.length, encoded[0]!.delivery.options?.id)
|
||||
}
|
||||
|
||||
;(window as BrowserTransport).__testSseTransport = { command }
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||
return originalFetch(input, init)
|
||||
|
||||
const id = ++nextConnectionID
|
||||
const record = {
|
||||
id,
|
||||
url: url.href,
|
||||
path: url.pathname,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
openedAt: performance.now(),
|
||||
} as Connection
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
record.controller = controller
|
||||
connections.push(record)
|
||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "abort"
|
||||
controller.error(request.signal.reason ?? new DOMException("The operation was aborted", "AbortError"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
},
|
||||
cancel() {
|
||||
if (record.endedAt !== undefined) return
|
||||
record.endedAt = performance.now()
|
||||
record.endedBy = "disconnect"
|
||||
},
|
||||
})
|
||||
return Promise.resolve(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ server, retry: options.retry },
|
||||
)
|
||||
|
||||
const command = <Result>(input: BrowserCommand<T>) =>
|
||||
page.evaluate((input) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
if (!transport) throw new Error("SSE transport was not installed before page load")
|
||||
return transport.command(input as BrowserCommand<unknown>)
|
||||
}, input) as Promise<Result>
|
||||
|
||||
return {
|
||||
server,
|
||||
async waitForConnection(input = {}) {
|
||||
await page.waitForFunction(
|
||||
(after) => {
|
||||
const transport = (window as BrowserTransport).__testSseTransport
|
||||
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
|
||||
return connections?.some((connection) => connection.id > after)
|
||||
},
|
||||
input.after ?? 0,
|
||||
{ timeout: input.timeout },
|
||||
)
|
||||
return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
|
||||
(connection) => connection.id > (input.after ?? 0),
|
||||
)!
|
||||
},
|
||||
send(payload, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
|
||||
},
|
||||
burst(payloads, eventOptions = []) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: payloads.map((payload, index) => ({ payload, options: eventOptions[index] })),
|
||||
burst: true,
|
||||
})
|
||||
},
|
||||
split(payload, cuts, eventOptions) {
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||
},
|
||||
heartbeat(eventOptions) {
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: [
|
||||
{
|
||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
||||
options: eventOptions,
|
||||
},
|
||||
],
|
||||
burst: false,
|
||||
})
|
||||
},
|
||||
writeRaw(value, cuts, marker) {
|
||||
return command({
|
||||
type: "raw",
|
||||
bytes: Array.from(typeof value === "string" ? new TextEncoder().encode(value) : value),
|
||||
cuts: cuts ? [...cuts] : undefined,
|
||||
marker,
|
||||
})
|
||||
},
|
||||
close() {
|
||||
return command({ type: "end", mode: "close" })
|
||||
},
|
||||
disconnect(message) {
|
||||
return command({ type: "end", mode: "disconnect", message })
|
||||
},
|
||||
error(message) {
|
||||
return command({ type: "end", mode: "error", message })
|
||||
},
|
||||
connections() {
|
||||
return command({ type: "connections" })
|
||||
},
|
||||
acknowledgements() {
|
||||
return command({ type: "acknowledgements" })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||
import { reportVisualStability } from "./visual-stability/reporter"
|
||||
|
||||
export * from "./visual-stability/index"
|
||||
|
||||
const capturedFrames = Symbol("capturedFrames")
|
||||
|
||||
export async function startVisualStabilityProbe(page: Page, regions: Record<string, VisualRegionDefinition>) {
|
||||
await startVisualProbe(page, regions)
|
||||
}
|
||||
|
||||
export async function stopVisualStabilityProbe(page: Page) {
|
||||
const result = await stopVisualProbe(page)
|
||||
const trace: VisualStabilityTrace = { markers: result.markers, samples: result.samples }
|
||||
Object.defineProperty(trace, capturedFrames, { value: result.frames })
|
||||
return trace
|
||||
}
|
||||
|
||||
export async function markVisualStability(page: Page, label: string) {
|
||||
await markVisualProbe(page, label)
|
||||
}
|
||||
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export function analyzeVisualStabilityByMarker(
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
||||
}
|
||||
|
||||
export async function expectVisualStability(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
name,
|
||||
{
|
||||
...trace,
|
||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||
},
|
||||
legacyVisualPlan(options),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { VisualInvariant, VisualPlan } from "./invariant"
|
||||
import type { VisualObservation, VisualStabilityTrace } from "./model"
|
||||
|
||||
export function analyzeVisualObservations<RegionName extends string>(
|
||||
observations: readonly VisualObservation<RegionName>[],
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const issues: string[] = []
|
||||
const invariants = plan.invariants
|
||||
const names = [...new Set(observations.flatMap((sample) => Object.keys(sample.regions) as RegionName[]))]
|
||||
const required = regions(invariants, "required")
|
||||
const continuousAny = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuous-any" }> =>
|
||||
invariant.type === "continuous-any",
|
||||
)
|
||||
const unique = new Set(regions(invariants, "unique"))
|
||||
const stable = new Set(regions(invariants, "stable"))
|
||||
const fixed = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "fixed" }> => invariant.type === "fixed",
|
||||
)
|
||||
const opacity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "opacity" }> => invariant.type === "opacity",
|
||||
)
|
||||
const continuity = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "continuity" }> =>
|
||||
invariant.type === "continuity",
|
||||
)
|
||||
const motion = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "motion" }> => invariant.type === "motion",
|
||||
)
|
||||
const labelStability = invariants.filter(
|
||||
(invariant): invariant is Extract<VisualInvariant<RegionName>, { type: "label-stability" }> =>
|
||||
invariant.type === "label-stability",
|
||||
)
|
||||
|
||||
for (const name of new Set(required)) {
|
||||
if (!observations.some((sample) => sample.regions[name]?.visible)) issues.push(`${name} never rendered`)
|
||||
}
|
||||
for (const invariant of continuousAny) {
|
||||
if (!invariant.regions.some((name) => observations.some((sample) => sample.regions[name]?.visible)))
|
||||
issues.push(`${invariant.regions.join(" | ")} never rendered`)
|
||||
}
|
||||
|
||||
for (const name of names) {
|
||||
const samples = observations.flatMap((observation) => {
|
||||
const region = observation.regions[name]
|
||||
if (!region) return []
|
||||
const clipped =
|
||||
observation.viewport && (region.bottom <= observation.viewport.top || region.top >= observation.viewport.bottom)
|
||||
return [{ at: observation.at, ...region, visible: region.visible && !clipped }]
|
||||
})
|
||||
const visible = samples.filter((sample) => sample.visible)
|
||||
if (visible.length === 0) continue
|
||||
if (unique.has(name)) {
|
||||
const duplicate = samples.find((sample) => sample.count > 1)
|
||||
if (duplicate) issues.push(`${name} appeared ${duplicate.count} times at ${Math.round(duplicate.at)}ms`)
|
||||
}
|
||||
if (stable.has(name)) {
|
||||
const identities = [...new Set(visible.map((sample) => sample.node).filter((node) => node > 0))]
|
||||
if (identities.length > 1) issues.push(`${name} remounted ${identities.length - 1} times`)
|
||||
}
|
||||
for (const invariant of fixed.filter((invariant) => includes(invariant.regions, name))) {
|
||||
const origin = visible[0]
|
||||
const movement = origin ? Math.max(0, ...visible.map((sample) => Math.abs(sample.top - origin.top))) : 0
|
||||
if (movement > (invariant.tolerance ?? 1))
|
||||
issues.push(`${name} moved ${Math.round(movement * 10) / 10}px in the viewport`)
|
||||
}
|
||||
for (const invariant of opacity.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const sample of visible) {
|
||||
if (sample.opacity < (invariant.floor ?? 0.65))
|
||||
issues.push(`${name} opacity fell to ${sample.opacity} at ${Math.round(sample.at)}ms`)
|
||||
}
|
||||
}
|
||||
if (continuity.some((invariant) => includes(invariant.regions, name))) {
|
||||
const firstPresent = samples.findIndex((sample) => sample.present)
|
||||
const lastPresent = samples.findLastIndex((sample) => sample.present)
|
||||
if (samples.slice(firstPresent, lastPresent + 1).some((sample) => !sample.present))
|
||||
issues.push(`${name} disappeared between present frames`)
|
||||
const firstVisible = samples.findIndex((sample) => sample.visible)
|
||||
const lastVisible = samples.findLastIndex((sample) => sample.visible)
|
||||
if (
|
||||
firstVisible >= 0 &&
|
||||
samples.slice(firstVisible, lastVisible + 1).some((sample) => !sample.visible && sample.inViewport)
|
||||
)
|
||||
issues.push(`${name} blanked between visible frames`)
|
||||
}
|
||||
for (const invariant of motion.filter((invariant) => includes(invariant.regions, name))) {
|
||||
for (const metric of ["top", "bottom", "width", "height"] as const) {
|
||||
const directions = visible
|
||||
.slice(1)
|
||||
.map((sample, index) => sample[metric] - visible[index]![metric])
|
||||
.filter((delta) => Math.abs(delta) > (invariant.tolerance ?? 1))
|
||||
.map(Math.sign)
|
||||
const reversals = directions.slice(1).filter((direction, index) => direction !== directions[index]).length
|
||||
const allowed =
|
||||
metric === "top" || metric === "bottom"
|
||||
? (invariant.maxPositionReversals ?? invariant.maxReversals ?? 1)
|
||||
: (invariant.maxReversals ?? 1)
|
||||
if (reversals > allowed) issues.push(`${name} ${metric} reversed ${reversals} times`)
|
||||
}
|
||||
}
|
||||
if (labelStability.some((invariant) => includes(invariant.regions, name))) {
|
||||
const labels = samples
|
||||
.map((sample) => sample.label)
|
||||
.filter((label) => label.length > 0)
|
||||
.filter((label, index, all) => label !== all[index - 1])
|
||||
if (labels.some((label, index) => labels.indexOf(label) !== index))
|
||||
issues.push(`${name} label reverted: ${labels.join(" -> ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (invariants.some((invariant) => invariant.type === "preserve-bottom-anchor")) {
|
||||
const viewports = observations.flatMap((sample) => (sample.viewport ? [sample.viewport] : []))
|
||||
if (viewports[0] && viewports[0].distanceFromBottom <= 4) {
|
||||
const lost = viewports.find((viewport) => viewport.distanceFromBottom > 4)
|
||||
if (lost) issues.push(`bottom anchor moved to ${lost.distanceFromBottom}px`)
|
||||
}
|
||||
}
|
||||
if (invariants.some((invariant) => invariant.type === "acquire-bottom-anchor")) {
|
||||
const final = observations.findLast((sample) => sample.viewport)?.viewport
|
||||
if (!final || final.distanceFromBottom > 4)
|
||||
issues.push(`did not acquire bottom anchor${final ? ` (${final.distanceFromBottom}px away)` : ""}`)
|
||||
}
|
||||
|
||||
for (const invariant of continuousAny) {
|
||||
const active = observations.map((sample) => invariant.regions.some((name) => sample.regions[name]?.visible))
|
||||
const first = active.indexOf(true)
|
||||
const last = active.lastIndexOf(true)
|
||||
if (first >= 0 && active.slice(first, last + 1).some((value) => !value))
|
||||
issues.push(`${invariant.regions.join(" | ")} blanked between visible frames`)
|
||||
}
|
||||
|
||||
for (const invariant of invariants.filter(
|
||||
(item): item is Extract<VisualInvariant<RegionName>, { type: "flow" }> => item.type === "flow",
|
||||
)) {
|
||||
for (const [before, after] of invariant.regions
|
||||
.slice(1)
|
||||
.map((after, index) => [invariant.regions[index]!, after])) {
|
||||
let maximum: { overlap: number; at: number } | undefined
|
||||
let inverted: { at: number } | undefined
|
||||
for (const sample of observations) {
|
||||
const first = sample.regions[before]
|
||||
const second = sample.regions[after]
|
||||
if (!first?.visible || !second?.visible) continue
|
||||
if (
|
||||
sample.viewport &&
|
||||
(first.bottom <= sample.viewport.top ||
|
||||
first.top >= sample.viewport.bottom ||
|
||||
second.bottom <= sample.viewport.top ||
|
||||
second.top >= sample.viewport.bottom)
|
||||
)
|
||||
continue
|
||||
const overlap = first.bottom - second.top
|
||||
if (first.top > second.top && !inverted) inverted = { at: sample.at }
|
||||
if (overlap > (invariant.overlapTolerance ?? 0.5) && (!maximum || overlap > maximum.overlap))
|
||||
maximum = { overlap, at: sample.at }
|
||||
}
|
||||
if (inverted) issues.push(`${before} rendered after ${after} at ${Math.round(inverted.at)}ms`)
|
||||
if (maximum)
|
||||
issues.push(
|
||||
`${before} overlapped ${after} by ${Math.round(maximum.overlap * 10) / 10}px at ${Math.round(maximum.at)}ms`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return [...new Set(issues)]
|
||||
}
|
||||
|
||||
export function analyzeVisualTraceByMarker<RegionName extends string>(
|
||||
trace: VisualStabilityTrace<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
if (trace.markers.length === 0) return analyzeVisualObservations(trace.samples, plan)
|
||||
const required = [...new Set(plan.markerRequired ?? regions(plan.invariants, "required"))].flatMap((name) =>
|
||||
trace.samples.some((sample) => sample.regions[name]?.visible) ? [] : [`${name} never rendered`],
|
||||
)
|
||||
const withoutRequired = plan.invariants.filter((invariant) => invariant.type !== "required")
|
||||
const windows = trace.markers.flatMap((marker, index) => {
|
||||
const end = trace.markers[index + 1]?.at ?? Infinity
|
||||
const before = trace.samples.findLast((sample) => sample.at < marker.at)
|
||||
const samples = [
|
||||
...(before ? [before] : []),
|
||||
...trace.samples.filter((sample) => sample.at >= marker.at && sample.at < end),
|
||||
]
|
||||
if (samples.length < 2) return []
|
||||
return analyzeVisualObservations(samples, { ...plan, perMarker: false, invariants: withoutRequired }).map(
|
||||
(issue) => `${marker.label}: ${issue}`,
|
||||
)
|
||||
})
|
||||
const aggregateMotion =
|
||||
plan.aggregateMotion === false
|
||||
? []
|
||||
: analyzeVisualObservations(trace.samples, {
|
||||
invariants: plan.invariants.filter((invariant) => invariant.type === "motion"),
|
||||
}).filter((issue) => / (?:top|bottom|width|height) reversed \d+ times$/.test(issue))
|
||||
return [...new Set([...required, ...aggregateMotion, ...windows])]
|
||||
}
|
||||
|
||||
function regions<RegionName extends string, Type extends VisualInvariant<RegionName>["type"]>(
|
||||
invariants: readonly VisualInvariant<RegionName>[],
|
||||
type: Type,
|
||||
) {
|
||||
return invariants.flatMap((invariant) =>
|
||||
invariant.type === type && "regions" in invariant && invariant.regions !== "all" ? [...invariant.regions] : [],
|
||||
) as RegionName[]
|
||||
}
|
||||
|
||||
function includes<RegionName extends string>(regions: readonly RegionName[] | "all", name: RegionName) {
|
||||
return regions === "all" || regions.includes(name)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CDPSession, Page } from "@playwright/test"
|
||||
import type { CapturedFrame } from "./model"
|
||||
|
||||
export type VisualCapture = {
|
||||
session: CDPSession
|
||||
frames: CapturedFrame[]
|
||||
startedAtEpoch: number
|
||||
running: boolean
|
||||
capture: Promise<void>
|
||||
}
|
||||
|
||||
export async function startVisualCapture(page: Page, startedAtEpoch: number) {
|
||||
if (process.env.OPENCODE_STABILITY_CAPTURE !== "1") return
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Page.enable")
|
||||
const recording: VisualCapture = {
|
||||
session,
|
||||
frames: [],
|
||||
startedAtEpoch,
|
||||
running: true,
|
||||
capture: Promise.resolve(),
|
||||
}
|
||||
recording.capture = (async () => {
|
||||
try {
|
||||
while (recording.running && recording.frames.length < 900) {
|
||||
const frame = await session.send("Page.captureScreenshot", {
|
||||
format: "jpeg",
|
||||
quality: 80,
|
||||
captureBeyondViewport: false,
|
||||
optimizeForSpeed: true,
|
||||
})
|
||||
recording.frames.push({ at: Date.now() - recording.startedAtEpoch, data: frame.data })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
} catch {
|
||||
recording.running = false
|
||||
}
|
||||
})()
|
||||
return recording
|
||||
}
|
||||
|
||||
export async function stopVisualCapture(recording: VisualCapture | undefined) {
|
||||
if (!recording) return []
|
||||
recording.running = false
|
||||
try {
|
||||
await recording.capture
|
||||
} finally {
|
||||
await recording.session.detach().catch(() => undefined)
|
||||
}
|
||||
return recording.frames
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./analyzer"
|
||||
export * from "./capture"
|
||||
export * from "./invariant"
|
||||
export * from "./model"
|
||||
export * from "./probe"
|
||||
export * from "./regions"
|
||||
export * from "./reporter"
|
||||
export * from "./scenario"
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type RegionSet<RegionName extends string> = readonly RegionName[] | "all"
|
||||
|
||||
export type VisualInvariant<RegionName extends string = string> =
|
||||
| { type: "required"; regions: readonly RegionName[] }
|
||||
| { type: "continuous-any"; regions: readonly RegionName[] }
|
||||
| { type: "unique"; regions: readonly RegionName[] }
|
||||
| { type: "stable"; regions: readonly RegionName[] }
|
||||
| { type: "fixed"; regions: readonly RegionName[]; tolerance?: number }
|
||||
| { type: "opacity"; regions: RegionSet<RegionName>; floor?: number }
|
||||
| {
|
||||
type: "motion"
|
||||
regions: RegionSet<RegionName>
|
||||
tolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
}
|
||||
| { type: "continuity"; regions: RegionSet<RegionName> }
|
||||
| { type: "label-stability"; regions: RegionSet<RegionName> }
|
||||
| { type: "flow"; regions: readonly RegionName[]; overlapTolerance?: number }
|
||||
| { type: "preserve-bottom-anchor" }
|
||||
| { type: "acquire-bottom-anchor" }
|
||||
|
||||
export type VisualPlan<RegionName extends string = string> = {
|
||||
regionNames?: readonly RegionName[]
|
||||
invariants: readonly VisualInvariant<RegionName>[]
|
||||
markerRequired?: readonly RegionName[]
|
||||
perMarker?: boolean
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
overlapTolerance?: number
|
||||
maxReversals?: number
|
||||
maxPositionReversals?: number
|
||||
stable?: RegionName[]
|
||||
fixed?: RegionName[]
|
||||
motion?: RegionName[]
|
||||
unique?: RegionName[]
|
||||
preserveBottomAnchor?: boolean
|
||||
acquireBottomAnchor?: boolean
|
||||
perMarker?: boolean
|
||||
continuousAny?: RegionName[][]
|
||||
required?: RegionName[]
|
||||
aggregateMotion?: boolean
|
||||
inferRequired?: boolean
|
||||
}
|
||||
|
||||
export function visualPlan<const Regions extends Record<string, VisualRegionDefinition>>(
|
||||
regions: Regions,
|
||||
invariants: readonly VisualInvariant<Extract<keyof Regions, string>>[],
|
||||
options: Omit<VisualPlan<Extract<keyof Regions, string>>, "regionNames" | "invariants"> = {},
|
||||
): VisualPlan<Extract<keyof Regions, string>> {
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
? []
|
||||
: [
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
]
|
||||
return {
|
||||
perMarker: options.perMarker,
|
||||
aggregateMotion: options.aggregateMotion,
|
||||
markerRequired: [
|
||||
...(options.required ?? []),
|
||||
...(options.stable ?? []),
|
||||
...(options.fixed ?? []),
|
||||
...(options.unique ?? []),
|
||||
...(options.motion ?? []),
|
||||
...(options.flow ?? []),
|
||||
],
|
||||
invariants: [
|
||||
{ type: "required", regions: [...(options.required ?? []), ...inferred] },
|
||||
...(options.continuousAny ?? []).map(
|
||||
(regions): VisualInvariant<RegionName> => ({ type: "continuous-any", regions }),
|
||||
),
|
||||
...(options.unique ? [{ type: "unique" as const, regions: options.unique }] : []),
|
||||
...(options.stable ? [{ type: "stable" as const, regions: options.stable }] : []),
|
||||
...(options.fixed
|
||||
? [{ type: "fixed" as const, regions: options.fixed, tolerance: options.motionTolerance }]
|
||||
: []),
|
||||
{ type: "opacity", regions: "all", floor: options.opacityFloor },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{
|
||||
type: "motion",
|
||||
regions: options.motion ?? "all",
|
||||
tolerance: options.motionTolerance,
|
||||
maxReversals: options.maxReversals,
|
||||
maxPositionReversals: options.maxPositionReversals,
|
||||
},
|
||||
{ type: "label-stability", regions: "all" },
|
||||
...(options.preserveBottomAnchor ? [{ type: "preserve-bottom-anchor" as const }] : []),
|
||||
...(options.acquireBottomAnchor ? [{ type: "acquire-bottom-anchor" as const }] : []),
|
||||
...(options.flow
|
||||
? [{ type: "flow" as const, regions: options.flow, overlapTolerance: options.overlapTolerance }]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export type VisualRegionSample = {
|
||||
present: boolean
|
||||
visible: boolean
|
||||
inViewport: boolean
|
||||
cssHidden?: boolean
|
||||
top: number
|
||||
bottom: number
|
||||
layoutTop?: number
|
||||
layoutBottom?: number
|
||||
width: number
|
||||
height: number
|
||||
opacity: number
|
||||
count: number
|
||||
node: number
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export type VisualViewportSample = {
|
||||
top: number
|
||||
bottom: number
|
||||
scrollTop: number
|
||||
scrollHeight: number
|
||||
clientHeight: number
|
||||
distanceFromBottom: number
|
||||
}
|
||||
|
||||
export type VisualObservation<RegionName extends string = string> = {
|
||||
at: number
|
||||
regions: string extends RegionName
|
||||
? Record<string, VisualRegionSample>
|
||||
: Partial<Record<RegionName, VisualRegionSample>>
|
||||
viewport?: VisualViewportSample
|
||||
}
|
||||
|
||||
export type VisualMarker = { at: number; label: string }
|
||||
|
||||
export type VisualStabilityTrace<RegionName extends string = string> = {
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
}
|
||||
|
||||
export type CapturedFrame = { at: number; data: string }
|
||||
|
||||
export type VisualProbeResult<RegionName extends string = string> = VisualStabilityTrace<RegionName> & {
|
||||
frames: CapturedFrame[]
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { startVisualCapture, stopVisualCapture, type VisualCapture } from "./capture"
|
||||
import type { VisualMarker, VisualObservation, VisualProbeResult } from "./model"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
|
||||
type ProbeWindow<RegionName extends string = string> = Window & {
|
||||
__visualStabilityProbe?: {
|
||||
startedAt: number
|
||||
markers: VisualMarker[]
|
||||
samples: VisualObservation<RegionName>[]
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const captures = new WeakMap<Page, VisualCapture>()
|
||||
|
||||
export async function startVisualProbe<Regions extends Record<string, VisualRegionDefinition>>(
|
||||
page: Page,
|
||||
regions: Regions,
|
||||
) {
|
||||
await stopCapture(page)
|
||||
await page.evaluate(() => {
|
||||
;(window as ProbeWindow).__visualStabilityProbe?.stop()
|
||||
})
|
||||
const startedAtEpoch = await page.evaluate((regions) => {
|
||||
const samples: VisualObservation[] = []
|
||||
const markers: VisualMarker[] = []
|
||||
const startedAt = performance.now()
|
||||
const nodes = new WeakMap<Node, number>()
|
||||
const lastBounds = new Map<string, { top: number; bottom: number }>()
|
||||
let nextNode = 1
|
||||
let running = true
|
||||
const round = (value: number) => Math.round(value * 10) / 10
|
||||
const opacity = (element: Element) => Number(getComputedStyle(element).opacity)
|
||||
const sample = () => {
|
||||
if (!running) return
|
||||
setTimeout(() => {
|
||||
if (!running) return
|
||||
const viewport = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
|
||||
element.querySelector("[data-timeline-row]"),
|
||||
)
|
||||
const viewportRect = viewport?.getBoundingClientRect()
|
||||
samples.push({
|
||||
at: performance.now() - startedAt,
|
||||
viewport: viewport
|
||||
? {
|
||||
top: round(viewportRect!.top),
|
||||
bottom: round(viewportRect!.bottom),
|
||||
scrollTop: round(viewport.scrollTop),
|
||||
scrollHeight: round(viewport.scrollHeight),
|
||||
clientHeight: round(viewport.clientHeight),
|
||||
distanceFromBottom: round(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop),
|
||||
}
|
||||
: undefined,
|
||||
regions: Object.fromEntries(
|
||||
Object.entries(regions).map(([name, config]) => {
|
||||
const found = document.querySelector<HTMLElement>(config.selector)
|
||||
const count = document.querySelectorAll(config.selector).length
|
||||
const element = config.closest ? found?.closest<HTMLElement>(config.closest) : found
|
||||
if (!element)
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: false,
|
||||
visible: false,
|
||||
inViewport: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
count,
|
||||
node: 0,
|
||||
label: "",
|
||||
text: "",
|
||||
},
|
||||
]
|
||||
const rect = element.getBoundingClientRect()
|
||||
const style = getComputedStyle(element)
|
||||
if (rect.height > 0) lastBounds.set(name, { top: rect.top, bottom: rect.bottom })
|
||||
const known = rect.height > 0 ? rect : lastBounds.get(name)
|
||||
const painted = (() => {
|
||||
const result = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right }
|
||||
let parent = element.parentElement
|
||||
while (parent) {
|
||||
const parentStyle = getComputedStyle(parent)
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowY)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.top = Math.max(result.top, parentRect.top)
|
||||
result.bottom = Math.min(result.bottom, parentRect.bottom)
|
||||
}
|
||||
if (["hidden", "clip", "scroll", "auto"].includes(parentStyle.overflowX)) {
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
result.left = Math.max(result.left, parentRect.left)
|
||||
result.right = Math.min(result.right, parentRect.right)
|
||||
}
|
||||
if (parent === viewport) break
|
||||
parent = parent.parentElement
|
||||
}
|
||||
if (viewportRect) {
|
||||
result.top = Math.max(result.top, viewportRect.top)
|
||||
result.bottom = Math.min(result.bottom, viewportRect.bottom)
|
||||
result.left = Math.max(result.left, viewportRect.left)
|
||||
result.right = Math.min(result.right, viewportRect.right)
|
||||
}
|
||||
return result
|
||||
})()
|
||||
const contentOpacity = config.opacitySelectors?.length
|
||||
? Math.max(
|
||||
0,
|
||||
...config.opacitySelectors.flatMap((selector) =>
|
||||
[...element.querySelectorAll(selector)].map((node) => {
|
||||
let value = 1
|
||||
let current: Element | null = node
|
||||
while (current) {
|
||||
value *= opacity(current)
|
||||
if (current === element) break
|
||||
current = current.parentElement
|
||||
}
|
||||
return value
|
||||
}),
|
||||
),
|
||||
)
|
||||
: opacity(element)
|
||||
let visibleOpacity = contentOpacity
|
||||
let ancestor = element.parentElement
|
||||
let ancestorHidden = false
|
||||
while (ancestor) {
|
||||
const ancestorStyle = getComputedStyle(ancestor)
|
||||
visibleOpacity *= Number(ancestorStyle.opacity)
|
||||
if (ancestorStyle.display === "none" || ancestorStyle.visibility === "hidden") ancestorHidden = true
|
||||
if (ancestor === viewport) break
|
||||
ancestor = ancestor.parentElement
|
||||
}
|
||||
const cssHidden =
|
||||
ancestorHidden || style.display === "none" || style.visibility === "hidden" || visibleOpacity === 0
|
||||
return [
|
||||
name,
|
||||
{
|
||||
present: true,
|
||||
visible:
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden" &&
|
||||
visibleOpacity > 0 &&
|
||||
painted.right > painted.left &&
|
||||
painted.bottom > painted.top,
|
||||
inViewport:
|
||||
!viewportRect || (!!known && known.bottom > viewportRect.top && known.top < viewportRect.bottom),
|
||||
cssHidden,
|
||||
top: round(painted.top),
|
||||
bottom: round(painted.bottom),
|
||||
layoutTop: round(rect.top),
|
||||
layoutBottom: round(rect.bottom),
|
||||
width: round(painted.right - painted.left),
|
||||
height: round(painted.bottom - painted.top),
|
||||
opacity: round(visibleOpacity),
|
||||
count,
|
||||
node: (() => {
|
||||
const current = nodes.get(element)
|
||||
if (current) return current
|
||||
nodes.set(element, nextNode)
|
||||
return nextNode++
|
||||
})(),
|
||||
label: element.getAttribute("aria-label") ?? "",
|
||||
text: (element.textContent ?? "").trim().replace(/\s+/g, " ").slice(0, 500),
|
||||
},
|
||||
]
|
||||
}),
|
||||
),
|
||||
})
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
}
|
||||
;(window as ProbeWindow).__visualStabilityProbe = {
|
||||
startedAt,
|
||||
markers,
|
||||
samples,
|
||||
stop: () => {
|
||||
running = false
|
||||
},
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
return new Promise<number>((resolve) => {
|
||||
const ready = () => {
|
||||
if (samples.length > 0) return resolve(performance.timeOrigin + startedAt)
|
||||
requestAnimationFrame(ready)
|
||||
}
|
||||
ready()
|
||||
})
|
||||
}, regions)
|
||||
const capture = await startVisualCapture(page, startedAtEpoch)
|
||||
if (capture) captures.set(page, capture)
|
||||
}
|
||||
|
||||
export async function stopVisualProbe<RegionName extends string = string>(
|
||||
page: Page,
|
||||
): Promise<VisualProbeResult<RegionName>> {
|
||||
return page
|
||||
.evaluate(() => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) throw new Error("Visual stability probe is not running")
|
||||
probe.stop()
|
||||
return { markers: probe.markers, samples: probe.samples }
|
||||
})
|
||||
.then(
|
||||
async (trace) => ({ ...trace, frames: await stopCapture(page) }) as unknown as VisualProbeResult<RegionName>,
|
||||
async (error: unknown) => {
|
||||
await stopCapture(page)
|
||||
throw error
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export async function markVisualProbe(page: Page, label: string) {
|
||||
await page.evaluate((label) => {
|
||||
const probe = (window as ProbeWindow).__visualStabilityProbe
|
||||
if (!probe) return
|
||||
probe.markers.push({ at: performance.now() - probe.startedAt, label })
|
||||
}, label)
|
||||
}
|
||||
|
||||
async function stopCapture(page: Page) {
|
||||
const capture = captures.get(page)
|
||||
if (capture) captures.delete(page)
|
||||
return stopVisualCapture(capture)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type VisualRegionDefinition = {
|
||||
selector: string
|
||||
closest?: string
|
||||
opacitySelectors?: readonly string[]
|
||||
}
|
||||
|
||||
export function defineVisualRegions<const Regions extends Record<string, VisualRegionDefinition>>(regions: Regions) {
|
||||
return regions
|
||||
}
|
||||
|
||||
export function mapVisualRegions<const Regions extends Record<string, VisualRegionDefinition>, Result>(
|
||||
regions: Regions,
|
||||
map: (region: Regions[keyof Regions], name: keyof Regions) => Result,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(regions).map(([name, region]) => [name, map(region as Regions[keyof Regions], name)]),
|
||||
) as { [Name in keyof Regions]: Result }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, type TestInfo } from "@playwright/test"
|
||||
import { writeFile } from "node:fs/promises"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./analyzer"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import type { VisualProbeResult } from "./model"
|
||||
|
||||
export async function reportVisualStability<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
plan: VisualPlan<RegionName>,
|
||||
) {
|
||||
const trace = { markers: result.markers, samples: result.samples }
|
||||
const issues = plan.perMarker
|
||||
? analyzeVisualTraceByMarker(trace, plan)
|
||||
: analyzeVisualObservations(result.samples, plan)
|
||||
const tracePath = testInfo.outputPath(`${name}-visual-trace.json`)
|
||||
const issuesPath = testInfo.outputPath(`${name}-visual-issues.json`)
|
||||
await writeFile(tracePath, JSON.stringify(trace, null, 2))
|
||||
await writeFile(
|
||||
issuesPath,
|
||||
JSON.stringify({ issues, markers: result.markers, capturedFrameCount: result.frames.length }, null, 2),
|
||||
)
|
||||
await testInfo.attach(`${name}-visual-trace`, { path: tracePath, contentType: "application/json" })
|
||||
await testInfo.attach(`${name}-visual-issues`, { path: issuesPath, contentType: "application/json" })
|
||||
if (issues.length) await attachViolationFrames(testInfo, name, result, issues)
|
||||
expect(issues, `${name}: ${issues.join("\n")}`).toEqual([])
|
||||
}
|
||||
|
||||
async function attachViolationFrames<RegionName extends string>(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
result: VisualProbeResult<RegionName>,
|
||||
issues: string[],
|
||||
) {
|
||||
if (result.frames.length === 0) return
|
||||
const targets = [
|
||||
...new Set(
|
||||
issues.flatMap((issue) => {
|
||||
const match = issue.match(/ at (\d+)ms/)
|
||||
if (match) return [Number(match[1])]
|
||||
const marker = result.markers.find((item) => issue.startsWith(`${item.label}:`))
|
||||
return marker ? [marker.at] : []
|
||||
}),
|
||||
),
|
||||
].slice(0, 6)
|
||||
for (const [violation, target] of targets.entries()) {
|
||||
const nearest = result.frames.reduce(
|
||||
(best, frame, index) => (Math.abs(frame.at - target) < Math.abs(result.frames[best]!.at - target) ? index : best),
|
||||
0,
|
||||
)
|
||||
for (const [label, index] of [
|
||||
["before", Math.max(0, nearest - 1)],
|
||||
["violation", nearest],
|
||||
["after", Math.min(result.frames.length - 1, nearest + 1)],
|
||||
] as const) {
|
||||
await testInfo.attach(`${name}-${violation + 1}-${label}-${Math.round(result.frames[index]!.at)}ms`, {
|
||||
body: Buffer.from(result.frames[index]!.data, "base64"),
|
||||
contentType: "image/jpeg",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import type { VisualPlan } from "./invariant"
|
||||
import { startVisualProbe, stopVisualProbe } from "./probe"
|
||||
import type { VisualRegionDefinition } from "./regions"
|
||||
import { reportVisualStability } from "./reporter"
|
||||
|
||||
export async function runVisualStabilityScenario<const Regions extends Record<string, VisualRegionDefinition>>(input: {
|
||||
page: Page
|
||||
testInfo: TestInfo
|
||||
name: string
|
||||
regions: Regions
|
||||
plan: VisualPlan<Extract<keyof Regions, string>>
|
||||
run: () => Promise<void>
|
||||
}) {
|
||||
await startVisualProbe(input.page, input.regions)
|
||||
await input.run()
|
||||
const result = await stopVisualProbe<Extract<keyof Regions, string>>(input.page)
|
||||
await reportVisualStability(input.testInfo, input.name, result, input.plan)
|
||||
return result
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.11",
|
||||
"version": "1.17.13",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
"typecheck:e2e": "tsgo -p e2e/tsconfig.json",
|
||||
"start": "vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -25,6 +26,7 @@
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report",
|
||||
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
|
||||
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
|
||||
},
|
||||
"license": "MIT",
|
||||
@@ -51,6 +53,7 @@
|
||||
"@dnd-kit/solid": "0.5.0",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
||||
+13
-109
@@ -31,20 +31,19 @@ import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||
import { CommentsProvider } from "@/context/comments"
|
||||
import { FileProvider } from "@/context/file"
|
||||
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||
import { ServerSyncProvider } from "@/context/server-sync"
|
||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||
import { HighlightsProvider } from "@/context/highlights"
|
||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider, useNotification } from "@/context/notification"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider, useSettings } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
@@ -53,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import {
|
||||
legacySessionHref,
|
||||
legacySessionServer,
|
||||
requireServerKey,
|
||||
selectSessionLineage,
|
||||
sessionHref,
|
||||
} from "./utils/session-route"
|
||||
import { isSessionNotFoundError } from "./utils/server-errors"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
|
||||
import Session from "@/pages/session"
|
||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome, LegacyHome } from "@/pages/home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
@@ -96,11 +88,7 @@ const SessionRoute = () => {
|
||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
)
|
||||
return <SessionPage />
|
||||
}
|
||||
|
||||
const TargetSessionRoute = () => {
|
||||
@@ -112,79 +100,19 @@ const TargetSessionRoute = () => {
|
||||
})
|
||||
|
||||
return (
|
||||
// Owns the server-identity remount. Session changes must NOT remount this
|
||||
// subtree (SessionRouteErrorBoundary resets and createSessionLineage
|
||||
// re-resolves reactively instead); both rely on this key for server changes.
|
||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<ResolvedTargetSessionRoute />
|
||||
<TargetSessionRouteContent />
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const cached = createMemo(() => sync().session.lineage.peek(params.id))
|
||||
const [resolved] = createResource(
|
||||
() => {
|
||||
if (cached()) return
|
||||
return { id: params.id, server: serverKey(), sync: sync() }
|
||||
},
|
||||
({ id, server, sync }) =>
|
||||
sync.session.lineage.resolve(id).catch((error) => {
|
||||
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionProviders>
|
||||
<Session />
|
||||
</SessionProviders>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||
// Notification/Models + the visual Layout) for that server.
|
||||
@@ -232,7 +160,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||
<ServerSDKProvider server={conn}>
|
||||
<ServerSyncProvider server={conn}>
|
||||
<TargetServerScopedProviders directory={directory}>
|
||||
<DraftServerScopedProviders directory={directory}>
|
||||
<SDKProvider directory={directory}>
|
||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||
<DraftProviders>
|
||||
@@ -240,7 +168,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||
</DraftProviders>
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</TargetServerScopedProviders>
|
||||
</DraftServerScopedProviders>
|
||||
</ServerSyncProvider>
|
||||
</ServerSDKProvider>
|
||||
</Show>
|
||||
@@ -365,38 +293,14 @@ function NewAppLayout(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(props: ServerScopedShellProps) {
|
||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||
function DraftProviders(props: ParentProps) {
|
||||
|
||||
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import type { Component } from "solid-js"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||
import "./settings-v2/settings-v2.css"
|
||||
|
||||
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
|
||||
|
||||
export const DialogManageModels: Component = () => {
|
||||
const local = useLocal()
|
||||
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogManageModelsV2: Component = () => {
|
||||
const local = useLocal()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
|
||||
const setProviderVisibility = (providerID: string, checked: boolean) => {
|
||||
providerList(providerID).forEach((x) => {
|
||||
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
|
||||
})
|
||||
}
|
||||
const setModelVisibility = (item: ModelItem, checked: boolean) => {
|
||||
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
|
||||
}
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: () => local.model.list(),
|
||||
key: (x) => `${x.provider.id}:${x.id}`,
|
||||
filterKeys: ["provider.name", "name", "id"],
|
||||
sortBy: (a, b) => a.name.localeCompare(b.name),
|
||||
groupBy: (x) => x.provider.id,
|
||||
sortGroupsBy: (a, b) => {
|
||||
const aRank = popularProviders.indexOf(a.category)
|
||||
const bRank = popularProviders.indexOf(b.category)
|
||||
const aPopular = aRank >= 0
|
||||
const bPopular = bRank >= 0
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
return aRank - bRank
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
|
||||
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
|
||||
<DialogTitleGroup
|
||||
title={language.t("dialog.model.manage")}
|
||||
description={language.t("dialog.model.manage.description")}
|
||||
/>
|
||||
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</ButtonV2>
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="px-4 pt-px pb-3">
|
||||
<div class="relative">
|
||||
<TextInputV2
|
||||
type="search"
|
||||
appearance="base"
|
||||
class="!w-full self-stretch"
|
||||
value={list.filter()}
|
||||
onInput={(event) => list.onInput(event.currentTarget.value)}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autofocus
|
||||
aria-label={language.t("dialog.model.search.placeholder")}
|
||||
/>
|
||||
<Show when={list.filter()}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
class="settings-v2-tab-search-clear"
|
||||
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
|
||||
onClick={() => list.clear()}
|
||||
aria-label={language.t("common.clear")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
|
||||
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
|
||||
<Show
|
||||
when={!list.grouped.loading}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={list.flat().length > 0}
|
||||
fallback={
|
||||
<div class="settings-v2-models-status">
|
||||
<span>{language.t("dialog.model.empty")}</span>
|
||||
<Show when={list.filter()}>
|
||||
<span class="settings-v2-models-status-filter">"{list.filter()}"</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={list.grouped.latest}>
|
||||
{(group) => (
|
||||
<div class="settings-v2-section" data-component="settings-models-provider">
|
||||
<div class="settings-v2-models-group-header justify-between">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
|
||||
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<SwitchV2
|
||||
class="mr-6"
|
||||
checked={providerVisible(group.category)}
|
||||
onChange={(checked) => setProviderVisibility(group.category, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{group.items[0].provider.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsListV2>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<SettingsRowV2 title={item.name} description="">
|
||||
<div>
|
||||
<SwitchV2
|
||||
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
|
||||
onChange={(checked) => setModelVisibility(item, checked)}
|
||||
hideLabel
|
||||
>
|
||||
{item.name}
|
||||
</SwitchV2>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)}
|
||||
</For>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
</DialogV2>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
|
||||
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||
type ModelItem = ReturnType<ModelState["list"]>[number]
|
||||
|
||||
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
|
||||
const manageKey = "action:manage"
|
||||
|
||||
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
|
||||
const aIndex = popularProviders.indexOf(a.category)
|
||||
const bIndex = popularProviders.indexOf(b.category)
|
||||
const aPopular = aIndex >= 0
|
||||
const bPopular = bIndex >= 0
|
||||
|
||||
if (aPopular && !bPopular) return -1
|
||||
if (!aPopular && bPopular) return 1
|
||||
if (aPopular && bPopular) return aIndex - bIndex
|
||||
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
|
||||
}
|
||||
|
||||
const ModelList: Component<{
|
||||
provider?: string
|
||||
@@ -59,7 +79,6 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
@@ -200,6 +219,271 @@ export function ModelSelectorPopover(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelSelectorPopoverV2(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
children?: JSX.Element
|
||||
triggerAs?: ValidComponent
|
||||
triggerProps?: ModelSelectorTriggerProps
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const model = props.model ?? useLocal().model
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||
let searchRef: HTMLInputElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let restoreTrigger = true
|
||||
|
||||
const allModels = createMemo(() =>
|
||||
model
|
||||
.list()
|
||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
||||
)
|
||||
const models = createMemo(() => {
|
||||
const search = store.search.trim().toLowerCase()
|
||||
const filtered = search
|
||||
? allModels().filter(
|
||||
(item) =>
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
: allModels()
|
||||
|
||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
const groups = createMemo(() => {
|
||||
const byProvider = new Map<string, ModelItem[]>()
|
||||
for (const item of models()) {
|
||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||
}
|
||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||
})
|
||||
const keys = () => [...models().map(modelKey), manageKey]
|
||||
const current = () => {
|
||||
const value = model.current()
|
||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
||||
}
|
||||
const initialActive = () => {
|
||||
const selected = current()
|
||||
const options = keys()
|
||||
if (selected && options.includes(selected)) return selected
|
||||
return options[0] ?? ""
|
||||
}
|
||||
const activeItem = () =>
|
||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||
const afterClose = (callback: () => void) => {
|
||||
const complete = () => {
|
||||
if (contentRef?.isConnected) {
|
||||
requestAnimationFrame(complete)
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
||||
}
|
||||
requestAnimationFrame(complete)
|
||||
}
|
||||
const setOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
restoreTrigger = true
|
||||
setStore({ open: true, active: initialActive() })
|
||||
setTimeout(() =>
|
||||
requestAnimationFrame(() => {
|
||||
searchRef?.focus()
|
||||
activeItem()?.scrollIntoView({ block: "nearest" })
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
setStore({ open: false, search: "", active: "" })
|
||||
}
|
||||
const select = (item: ModelItem) => {
|
||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||
props.onClose?.()
|
||||
}
|
||||
const selectModel = (item: ModelItem) => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => select(item))
|
||||
}
|
||||
const manage = () => {
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
||||
})
|
||||
})
|
||||
}
|
||||
const selectActive = () => {
|
||||
const item = models().find((item) => modelKey(item) === store.active)
|
||||
if (item) {
|
||||
selectModel(item)
|
||||
return
|
||||
}
|
||||
if (store.active === manageKey) manage()
|
||||
}
|
||||
const moveActive = (delta: number) => {
|
||||
const options = keys()
|
||||
if (options.length === 0) return
|
||||
const index = options.indexOf(store.active)
|
||||
const start = index === -1 ? 0 : index
|
||||
setStore("active", options[(start + delta + options.length) % options.length])
|
||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||
}
|
||||
const setSearch = (value: string) => {
|
||||
const search = value.trim().toLowerCase()
|
||||
const first = [...allModels()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.find(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLowerCase().includes(search) ||
|
||||
item.id.toLowerCase().includes(search) ||
|
||||
item.provider.name.toLowerCase().includes(search),
|
||||
)
|
||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
||||
{props.children}
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content
|
||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
||||
onFocusOutside={() => (restoreTrigger = false)}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col p-0.5">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => (searchRef = el)}
|
||||
value={store.search}
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
onInput={(event) => setSearch(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Tab") return
|
||||
event.stopPropagation()
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
restoreTrigger = false
|
||||
setOpen(false)
|
||||
afterClose(() => props.onClose?.())
|
||||
return
|
||||
}
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault()
|
||||
moveActive(1)
|
||||
return
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault()
|
||||
moveActive(-1)
|
||||
return
|
||||
}
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
selectActive()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Show when={store.search.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => setSearch("")}
|
||||
aria-label={language.t("common.clear")}
|
||||
>
|
||||
<Icon name="close" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
|
||||
<div class="flex flex-col p-0.5 pt-0">
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={
|
||||
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
{language.t("dialog.model.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<MenuV2.Group>
|
||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
</MenuV2.GroupLabel>
|
||||
<MenuV2.RadioGroup value={current()}>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<MenuV2.RadioItem
|
||||
value={modelKey(item)}
|
||||
data-option-key={modelKey(item)}
|
||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
||||
class="scroll-my-6"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", modelKey(item))
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||
</Show>
|
||||
<Show when={item.latest}>
|
||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||
</Show>
|
||||
</MenuV2.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Group>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col p-0.5">
|
||||
<MenuV2.Item
|
||||
data-option-key={manageKey}
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
|
||||
onMouseEnter={() => {
|
||||
setStore("active", manageKey)
|
||||
setTimeout(() => searchRef?.focus())
|
||||
}}
|
||||
onSelect={manage}
|
||||
>
|
||||
<Icon name="outline-sliders" size="small" />
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</MenuV2.Item>
|
||||
</div>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
)
|
||||
}
|
||||
|
||||
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import {
|
||||
dirsToExpand,
|
||||
pathToFileUrl,
|
||||
shouldListRoot,
|
||||
visibleKind,
|
||||
withFileDragImage,
|
||||
type Filter,
|
||||
type Kind,
|
||||
} from "@/components/file-tree"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
|
||||
const nodes = children(path)
|
||||
if (!current) return nodes
|
||||
|
||||
const parent = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return item.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
return idx === -1 ? item : item.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
if (type === "directory") return 8 + level * INDENT_STEP
|
||||
if (level === 0) return 8
|
||||
return 8 + level * INDENT_STEP - INDENT_STEP
|
||||
}
|
||||
|
||||
function guideLineLeft(level: number) {
|
||||
return rowPaddingLeft(level, "directory") + 8
|
||||
}
|
||||
|
||||
export const kindLabel = (kind: Kind) => {
|
||||
if (kind === "add") return "A"
|
||||
if (kind === "del") return "D"
|
||||
return ""
|
||||
}
|
||||
|
||||
export const kindChange = (kind: Kind) => {
|
||||
if (kind === "add") return "added"
|
||||
if (kind === "del") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
const FileTreeNodeV2 = (
|
||||
p: ParentProps &
|
||||
ComponentProps<"div"> &
|
||||
ComponentProps<"button"> & {
|
||||
node: FileNode
|
||||
level: number
|
||||
active?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
const [local, rest] = splitProps(p, [
|
||||
"node",
|
||||
"level",
|
||||
"active",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
data-ignored={local.node.ignored ? "" : undefined}
|
||||
classList={{
|
||||
...local.classList,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`}
|
||||
draggable={local.draggable}
|
||||
onDragStart={(event: DragEvent) => {
|
||||
if (!local.draggable) return
|
||||
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
|
||||
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
|
||||
withFileDragImage(event)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{local.children}
|
||||
<span class="flex-1 min-w-0 text-12-medium whitespace-nowrap truncate">{local.node.name}</span>
|
||||
{(() => {
|
||||
const value = kind()
|
||||
if (!value || local.node.type !== "file") return null
|
||||
return (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
|
||||
{kindLabel(value)}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</Dynamic>
|
||||
)
|
||||
}
|
||||
|
||||
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
|
||||
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
|
||||
// synthesized from that list) for nested content to appear.
|
||||
export default function FileTreeV2(props: {
|
||||
path: string
|
||||
active?: string
|
||||
level?: number
|
||||
allowed?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
const allowed = props.allowed
|
||||
if (!allowed) return
|
||||
|
||||
const files = new Set(allowed)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
for (const item of allowed) {
|
||||
const parts = item.split("/")
|
||||
const parents = parts.slice(0, -1)
|
||||
for (const [idx] of parents.entries()) {
|
||||
const dir = parents.slice(0, idx + 1).join("/")
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
return { files, dirs }
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
if (props._marks) return props._marks
|
||||
|
||||
const out = new Set<string>(props.kinds?.keys() ?? [])
|
||||
if (out.size === 0) return
|
||||
return out
|
||||
})
|
||||
|
||||
const kinds = createMemo(() => {
|
||||
if (props._kinds) return props._kinds
|
||||
return props.kinds
|
||||
})
|
||||
|
||||
const deeps = createMemo(() => {
|
||||
if (props._deeps) return props._deeps
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
})
|
||||
// Nodes come from the `allowed` filter; skip listing so directories that only
|
||||
// exist on the diff's base branch do not each fail with an error toast.
|
||||
for (const dir of dirs) file.tree.expand(dir, { list: false })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
)
|
||||
|
||||
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
|
||||
|
||||
return (
|
||||
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
|
||||
// an outer group with the same name to widen the hover area.
|
||||
<div data-component="file-tree-v2" class="group/file-tree-v2">
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
data-scope="file-tree-v2"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
|
||||
}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasChildren()}>
|
||||
<Collapsible.Content class="relative">
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
||||
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
|
||||
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
|
||||
}}
|
||||
style={`left: ${guideLineLeft(level)}px`}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTreeV2
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<Show when={level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<Show
|
||||
when={!node.ignored}
|
||||
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Show>
|
||||
</FileTreeNodeV2>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,13 +21,13 @@ import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function pathToFileUrl(filepath: string): string {
|
||||
export function pathToFileUrl(filepath: string): string {
|
||||
return `file://${encodeFilePath(filepath)}`
|
||||
}
|
||||
|
||||
type Kind = "add" | "del" | "mix"
|
||||
export type Kind = "add" | "del" | "mix"
|
||||
|
||||
type Filter = {
|
||||
export type Filter = {
|
||||
files: Set<string>
|
||||
dirs: Set<string>
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const kindDotColor = (kind: Kind) => {
|
||||
return "background-color: var(--icon-diff-modified-base)"
|
||||
}
|
||||
|
||||
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
export const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
|
||||
const kind = kinds?.get(node.path)
|
||||
if (!kind) return
|
||||
if (!marks?.has(node.path)) return
|
||||
@@ -99,7 +99,7 @@ const buildDragImage = (target: HTMLElement) => {
|
||||
return image
|
||||
}
|
||||
|
||||
const withFileDragImage = (event: DragEvent) => {
|
||||
export const withFileDragImage = (event: DragEvent) => {
|
||||
const image = buildDragImage(event.currentTarget as HTMLElement)
|
||||
if (!image) return
|
||||
document.body.appendChild(image)
|
||||
|
||||
@@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { usePermission } from "@/context/permission"
|
||||
@@ -1480,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
model: props.controls.model.selection,
|
||||
providerID: props.controls.model.selection.current()?.provider?.id,
|
||||
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
|
||||
newLayoutDesigns: props.controls.newLayoutDesigns,
|
||||
style: control(),
|
||||
onClose: restoreFocus,
|
||||
onUnpaidClick: () => {
|
||||
@@ -1646,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
|
||||
<div
|
||||
data-component="prompt-variant-control"
|
||||
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
|
||||
classList={{
|
||||
"animate-in fade-in": providersShouldFadeIn(),
|
||||
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
|
||||
@@ -1662,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
size="normal"
|
||||
options={variants()}
|
||||
current={props.controls.model.selection.variant.current() ?? "default"}
|
||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||
<MenuV2
|
||||
gutter={6}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
onOpenChange={(open) => setStore("variantOpen", open)}
|
||||
onSelect={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
|
||||
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
triggerStyle={control()}
|
||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||
variant="ghost"
|
||||
/>
|
||||
>
|
||||
<MenuV2.Trigger
|
||||
as={ButtonV2}
|
||||
data-action="prompt-model-variant"
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="max-w-[160px] justify-start capitalize"
|
||||
style={control()}
|
||||
>
|
||||
<span class="truncate">
|
||||
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
<MenuV2.Portal>
|
||||
<MenuV2.Content>
|
||||
<MenuV2.RadioGroup
|
||||
value={props.controls.model.selection.variant.current() ?? "default"}
|
||||
onChange={(value) => {
|
||||
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
|
||||
restoreFocus()
|
||||
}}
|
||||
>
|
||||
{variants().map((value) => (
|
||||
<MenuV2.RadioItem value={value} class="capitalize">
|
||||
{value === "default" ? language.t("common.default") : value}
|
||||
</MenuV2.RadioItem>
|
||||
))}
|
||||
</MenuV2.RadioGroup>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -2057,6 +2084,7 @@ type ComposerModelControlState = {
|
||||
model: ReturnType<typeof useLocal>["model"]
|
||||
providerID?: string
|
||||
modelName: string
|
||||
newLayoutDesigns: boolean
|
||||
style: JSX.CSSProperties | undefined
|
||||
onClose: () => void
|
||||
onUnpaidClick: () => void
|
||||
@@ -2147,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
<Show
|
||||
when={props.state.newLayoutDesigns}
|
||||
fallback={
|
||||
<ModelSelectorPopover
|
||||
model={props.state.model}
|
||||
triggerAs={Button}
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class:
|
||||
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} />
|
||||
</ModelSelectorPopover>
|
||||
}
|
||||
>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class="-ml-1 shrink-0 flex size-fit">
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</ModelSelectorPopover>
|
||||
<ModelSelectorPopoverV2
|
||||
model={props.state.model}
|
||||
triggerAs={ButtonV2}
|
||||
triggerProps={{
|
||||
variant: "ghost-muted",
|
||||
size: "normal",
|
||||
style: props.state.style,
|
||||
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
|
||||
classList: { "animate-in fade-in": props.state.shouldAnimate },
|
||||
"data-action": "prompt-model",
|
||||
}}
|
||||
onClose={props.state.onClose}
|
||||
>
|
||||
<ModelControlContent state={props.state} v2 />
|
||||
</ModelSelectorPopoverV2>
|
||||
</Show>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.state.providerID}>
|
||||
{(providerID) => (
|
||||
<ProviderIcon
|
||||
id={providerID()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{props.state.modelName}</span>
|
||||
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
|
||||
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createPromptSubmissionState(input: {
|
||||
prompt: Prompt
|
||||
context: (ContextItem & { key: string })[]
|
||||
}) {
|
||||
const initial = input.target
|
||||
let target = input.target
|
||||
let cleared: Prompt | undefined
|
||||
|
||||
@@ -15,6 +16,7 @@ export function createPromptSubmissionState(input: {
|
||||
context: input.context,
|
||||
target: () => target,
|
||||
clear() {
|
||||
if (initial !== target) initial.reset()
|
||||
target.reset()
|
||||
cleared = target.current()
|
||||
},
|
||||
|
||||
@@ -92,11 +92,17 @@ export function PromptWorkspaceSelector(props: {
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{props.branch || "main"}</span>
|
||||
</div>
|
||||
<Show when={props.branch}>
|
||||
{(branch) => (
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
|
||||
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{branch()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
|
||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -18,7 +18,16 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
buttonAppearance?: "default" | "v2"
|
||||
placement?: TooltipProps["placement"]
|
||||
placement?: ComponentProps<typeof TooltipV2>["placement"]
|
||||
}
|
||||
|
||||
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
|
||||
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function openSessionContext(args: {
|
||||
@@ -113,38 +122,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
<Show when={tokens()}>
|
||||
{(value) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">
|
||||
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
|
||||
</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={context()}>
|
||||
{(ctx) => (
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-text-invert-strong">{cost()}</span>
|
||||
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
|
||||
</div>
|
||||
<div class="flex w-[120px] flex-col gap-2">
|
||||
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
|
||||
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
|
||||
<ContextTooltipRow
|
||||
name={language.t("context.usage.tokens")}
|
||||
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={params.id}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={buttonAppearance() === "v2"}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
|
||||
<Switch>
|
||||
<Match when={variant() === "indicator"}>{circle()}</Match>
|
||||
<Match when={buttonAppearance() === "v2"}>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
@@ -153,10 +146,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
onClick={openContext}
|
||||
aria-label={language.t("context.usage.view")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -166,9 +157,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
>
|
||||
{circle()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -541,6 +541,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
|
||||
</Show>
|
||||
<Show when={props.state.reviewVisible}>
|
||||
<TooltipV2
|
||||
class="shrink-0"
|
||||
placement="bottom"
|
||||
value={
|
||||
<>
|
||||
|
||||
@@ -5,12 +5,13 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { projectForSession } from "@/pages/layout/helpers"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
|
||||
import { TabPreviewPopover } from "./titlebar-tab-popover"
|
||||
import "./titlebar-tab-nav.css"
|
||||
|
||||
// MouseEvent.button uses 1 for the middle/wheel button.
|
||||
@@ -21,12 +22,12 @@ export function TabNavItem(props: {
|
||||
href: string
|
||||
server: ServerConnection.Key
|
||||
session: () => Session | undefined
|
||||
fallbackTitle?: string
|
||||
onTitleChange?: (title: string) => void
|
||||
onTitleChangeFailed?: (title: string) => void
|
||||
onClose: () => void
|
||||
onNavigate: () => void
|
||||
active?: boolean
|
||||
activeServer: boolean
|
||||
forceTruncate?: boolean
|
||||
suppressNavigation?: () => boolean
|
||||
dragging?: boolean
|
||||
@@ -56,6 +57,28 @@ export function TabNavItem(props: {
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
})
|
||||
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
return displayName(project() ?? { worktree: session.directory })
|
||||
})
|
||||
const previewPath = createMemo(() => {
|
||||
const session = props.session()
|
||||
if (!session) return
|
||||
const home = serverCtx()?.sync.data.path.home
|
||||
return home ? session.directory.replace(home, "~") : session.directory
|
||||
})
|
||||
// Only label the server when multiple servers are connected.
|
||||
const serverLabel = createMemo(() => {
|
||||
if (global.servers.list().length <= 1) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||
return conn ? serverName(conn) : undefined
|
||||
})
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = createSignal(false)
|
||||
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
|
||||
|
||||
const measureTitleOverflow = () => {
|
||||
if (!titleEl || editing()) {
|
||||
@@ -74,7 +97,7 @@ export function TabNavItem(props: {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
props.session()?.title
|
||||
title()
|
||||
props.forceTruncate
|
||||
editing()
|
||||
scheduleTitleOverflow()
|
||||
@@ -133,9 +156,9 @@ export function TabNavItem(props: {
|
||||
createEffect(() => {
|
||||
if (editing()) return
|
||||
if (!titleEl) return
|
||||
const title = props.session()?.title
|
||||
if (title === undefined) return
|
||||
titleEl.textContent = title
|
||||
const value = title()
|
||||
if (value === undefined) return
|
||||
titleEl.textContent = value
|
||||
})
|
||||
|
||||
const openRename = (event: MouseEvent) => {
|
||||
@@ -171,7 +194,7 @@ export function TabNavItem(props: {
|
||||
onCleanup(cleanup)
|
||||
})
|
||||
|
||||
return (
|
||||
const tab = (
|
||||
<div
|
||||
ref={(el) => {
|
||||
tabRoot = el
|
||||
@@ -196,73 +219,82 @@ export function TabNavItem(props: {
|
||||
closeTab(event)
|
||||
}}
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => {
|
||||
return (
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={title() !== undefined}>
|
||||
<a
|
||||
data-slot="tab-link"
|
||||
data-titlebar-tab-link
|
||||
href={props.href}
|
||||
draggable={false}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (editing()) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
|
||||
>
|
||||
<Show when={props.session()}>
|
||||
{(session) => (
|
||||
<span data-slot="project-avatar-slot">
|
||||
<SessionTabAvatar
|
||||
project={project()}
|
||||
directory={session().directory}
|
||||
sessionId={session().id}
|
||||
activeServer={props.activeServer}
|
||||
server={props.server}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = session().title
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = session().title
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
)}
|
||||
</Show>
|
||||
<span
|
||||
ref={(el) => {
|
||||
titleEl = el
|
||||
titleEl.textContent = title() ?? ""
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
"select-text": editing(),
|
||||
}}
|
||||
contenteditable={editing() ? true : undefined}
|
||||
onDblClick={openRename}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void closeRename(true)
|
||||
return
|
||||
}
|
||||
if (event.key !== "Escape") return
|
||||
event.preventDefault()
|
||||
titleEl.textContent = props.session()?.title ?? ""
|
||||
void closeRename(false)
|
||||
}}
|
||||
onBlur={() => void closeRename(true)}
|
||||
onPointerDown={(event) => {
|
||||
if (!editing()) return
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (!editing()) return
|
||||
event.preventDefault()
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</Show>
|
||||
|
||||
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
|
||||
@@ -280,6 +312,23 @@ export function TabNavItem(props: {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<TabPreviewPopover
|
||||
trigger={tab}
|
||||
open={popoverOpen() && !previewBlocked()}
|
||||
onOpenChange={(value) => {
|
||||
if (value && previewBlocked()) return
|
||||
setPopoverOpen(value)
|
||||
}}
|
||||
data={{
|
||||
projectName: projectName(),
|
||||
title: props.session()?.title,
|
||||
path: previewPath(),
|
||||
serverName: serverLabel(),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DraftTabItem(props: {
|
||||
@@ -328,8 +377,16 @@ export function DraftTabItem(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Navigate on mousedown to shave the press-release delay off tab switches.
|
||||
if (event.button !== 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
// Mouse navigation already happened on mousedown; detail 0 means keyboard activation.
|
||||
if (event.detail > 0) return
|
||||
if (props.suppressNavigation?.()) return
|
||||
props.onNavigate()
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
[data-component="session-tab-popover-trigger"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="session-tab-popover"] {
|
||||
z-index: 50;
|
||||
box-sizing: border-box;
|
||||
width: 256px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
|
||||
background: var(--v2-background-bg-base);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
|
||||
color: var(--v2-text-text-base);
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
|
||||
transform-origin: var(--kb-hovercard-content-transform-origin);
|
||||
/* Entrance: instant (trying no animation — may revert to 120ms ease-out). */
|
||||
animation: none;
|
||||
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Exit: instant (trying no animation — may revert to 80ms ease-out). */
|
||||
&[data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Warm streak (rapid tab hopping): appear/disappear instantly so the enter
|
||||
and exit animations don't repeat on every tab. */
|
||||
&[data-instant],
|
||||
&[data-instant][data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
[data-slot="header"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="project"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="title"] {
|
||||
font-weight: 530;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-base);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="row"] {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="detail"] {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
/* Wrap the full path onto the next line instead of clipping the end. */
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="server"] {
|
||||
font-weight: 440;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04px;
|
||||
color: var(--v2-text-text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sessionTabPopoverOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-component="session-tab-popover"],
|
||||
[data-component="session-tab-popover"][data-closed] {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
|
||||
import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 200
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
|
||||
// tabs doesn't re-wait the full delay each time.
|
||||
const SKIP_WINDOW = 500
|
||||
let lastClosedAt = 0
|
||||
|
||||
export interface TabPreviewData {
|
||||
projectName?: string
|
||||
title?: string
|
||||
path?: string
|
||||
serverName?: string
|
||||
}
|
||||
|
||||
export function TabPreviewPopover(props: {
|
||||
trigger: JSXElement
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
data: TabPreviewData
|
||||
}) {
|
||||
let triggerEl: HTMLDivElement | undefined
|
||||
// When opened during a rapid tab-hopping streak, this preview appears and
|
||||
// disappears instantly (no repeated enter/exit animation) — only the first,
|
||||
// "cold" preview animates. Mirrors how browsers reuse one tab tooltip.
|
||||
const [instant, setInstant] = createSignal(false)
|
||||
|
||||
const warm = () => Date.now() - lastClosedAt < SKIP_WINDOW
|
||||
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
|
||||
// resolves the skip window per-hover.
|
||||
const resolveOpenDelay = () => (warm() ? 0 : OPEN_DELAY)
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) setInstant(warm())
|
||||
else lastClosedAt = Date.now()
|
||||
props.onOpenChange(open)
|
||||
}
|
||||
|
||||
return (
|
||||
<Kobalte
|
||||
open={props.open}
|
||||
onOpenChange={handleOpenChange}
|
||||
openDelay={resolveOpenDelay()}
|
||||
closeDelay={CLOSE_DELAY}
|
||||
// The preview is non-interactive (pointer-events: none), so there is no
|
||||
// safe area to traverse — leaving the tab hides it immediately.
|
||||
ignoreSafeArea
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
>
|
||||
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
|
||||
{props.trigger}
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Content
|
||||
ref={(el) => {
|
||||
// Portalled content lives outside the themed subtree, so mirror the
|
||||
// active theme like the v2 tooltip does.
|
||||
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
|
||||
if (theme) el.setAttribute("data-theme", theme)
|
||||
}}
|
||||
data-component="session-tab-popover"
|
||||
data-instant={instant() || undefined}
|
||||
>
|
||||
<div data-slot="header">
|
||||
<Show when={props.data.projectName}>
|
||||
<span data-slot="project">{props.data.projectName}</span>
|
||||
</Show>
|
||||
<Show when={props.data.title}>
|
||||
<span data-slot="title">{props.data.title}</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={props.data.path}>
|
||||
<div data-slot="row">
|
||||
<span data-slot="detail">{props.data.path}</span>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.data.serverName}>
|
||||
<div data-slot="server">{props.data.serverName}</div>
|
||||
</Show>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
@@ -24,13 +24,13 @@ function SessionTabSlot(props: {
|
||||
id: string
|
||||
index: () => number
|
||||
active: () => boolean
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
serverCtx: () => ServerCtx | undefined
|
||||
onNavigate: (element: HTMLDivElement) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.id
|
||||
@@ -50,6 +50,7 @@ function SessionTabSlot(props: {
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
|
||||
let prefetched = false
|
||||
|
||||
createEffect(() => {
|
||||
@@ -87,7 +88,7 @@ function SessionTabSlot(props: {
|
||||
data-tab-key={props.id}
|
||||
data-active={props.active()}
|
||||
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
|
||||
classList={{ hidden: !session() }}
|
||||
classList={{ hidden: !session() && !missingSession() }}
|
||||
>
|
||||
<TabNavItem
|
||||
ref={(el) => {
|
||||
@@ -96,6 +97,7 @@ function SessionTabSlot(props: {
|
||||
href={tabHref(props.tab)}
|
||||
server={props.tab.server}
|
||||
session={session}
|
||||
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
|
||||
onTitleChange={(title) => {
|
||||
const value = session()
|
||||
const ctx = props.serverCtx()
|
||||
@@ -109,7 +111,6 @@ function SessionTabSlot(props: {
|
||||
onNavigate={() => props.onNavigate(ref)}
|
||||
onClose={props.onClose}
|
||||
active={props.active()}
|
||||
activeServer={props.tab.server === props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
dragging={sortable.isDragSource()}
|
||||
/>
|
||||
@@ -162,7 +163,6 @@ function DraftTabSlot(props: {
|
||||
export function TitlebarTabStrip(props: {
|
||||
tabs: Tab[]
|
||||
currentTab: () => Tab | undefined
|
||||
activeServerKey: ServerConnection.Key
|
||||
forceTruncate: boolean
|
||||
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
|
||||
onClose: (tab: Tab) => void
|
||||
@@ -268,7 +268,6 @@ export function TitlebarTabStrip(props: {
|
||||
id={id}
|
||||
index={index}
|
||||
active={() => props.currentTab() === tab}
|
||||
activeServerKey={props.activeServerKey}
|
||||
forceTruncate={props.forceTruncate}
|
||||
serverCtx={serverCtx}
|
||||
onNavigate={(element) => {
|
||||
|
||||
@@ -456,7 +456,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
<TitlebarTabStrip
|
||||
tabs={tabsStore}
|
||||
currentTab={currentTab}
|
||||
activeServerKey={server.key}
|
||||
forceTruncate={tabsAreOverflowing()}
|
||||
onOverflowChange={setTabsAreOverflowing}
|
||||
onNavigate={(tab, el) => {
|
||||
@@ -572,7 +571,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={2000}
|
||||
openDelay={800}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -602,7 +601,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@@ -612,7 +611,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -85,7 +85,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
|
||||
active: null as CommentFocus | null,
|
||||
})
|
||||
|
||||
const all = () => aggregate(store.comments)
|
||||
// Reuse the previous array when contents are unchanged so consumers keep a stable
|
||||
// identity; a fresh array per call cascaded into diff annotation re-renders.
|
||||
let lastAll: LineComment[] = []
|
||||
const all = () => {
|
||||
const next = aggregate(store.comments)
|
||||
if (next.length === lastAll.length && next.every((item, index) => item === lastAll[index])) return lastAll
|
||||
lastAll = next
|
||||
return next
|
||||
}
|
||||
|
||||
const setRef = (
|
||||
key: "focus" | "active",
|
||||
|
||||
@@ -127,10 +127,14 @@ export function createFileTreeStore(options: TreeStoreOptions) {
|
||||
return promise
|
||||
}
|
||||
|
||||
const expandDir = (input: string) => {
|
||||
// `list: false` marks a directory expanded without fetching its children, for
|
||||
// trees whose nodes are synthesized from a filter; listing directories that
|
||||
// only exist on a diff's base branch fails and surfaces error toasts.
|
||||
const expandDir = (input: string, behavior?: { list?: boolean }) => {
|
||||
const dir = options.normalizeDir(input)
|
||||
ensureDir(dir)
|
||||
setTree("dir", dir, "expanded", true)
|
||||
if (behavior?.list === false) return
|
||||
void listDir(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { createServerSession } from "../server-session"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
|
||||
function directoryState() {
|
||||
return createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
}
|
||||
|
||||
describe("bootstrapDirectory", () => {
|
||||
test("marks a loading directory partial during bootstrap and complete after success", async () => {
|
||||
const mcpReads: string[] = []
|
||||
const [store, setStore] = createStore<State>({
|
||||
status: "loading",
|
||||
agent: [],
|
||||
command: [],
|
||||
reference: [],
|
||||
project: "",
|
||||
projectMeta: undefined,
|
||||
icon: undefined,
|
||||
provider_ready: true,
|
||||
provider,
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
session_status: {},
|
||||
session_working(id: string) {
|
||||
return this.session_status[id]?.type !== "idle"
|
||||
},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: true,
|
||||
mcp: {},
|
||||
mcp_resource: {},
|
||||
lsp_ready: true,
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
part_text_accum_delta: {},
|
||||
})
|
||||
const [store, setStore] = directoryState()
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
@@ -93,6 +98,66 @@ describe("bootstrapDirectory", () => {
|
||||
expect(store.status).toBe("complete")
|
||||
expect(mcpReads).toEqual([])
|
||||
})
|
||||
|
||||
test("seeds session status even while warming session info stalls", async () => {
|
||||
const [store, setStore] = directoryState()
|
||||
const stalled = Promise.withResolvers<never>()
|
||||
const client = {
|
||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
session: {
|
||||
status: async () => ({ data: { ses_busy: { type: "busy" } } }),
|
||||
get: () => stalled.promise,
|
||||
},
|
||||
vcs: { get: async () => ({ data: undefined }) },
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: { status: async () => ({ data: {} }) },
|
||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
||||
} as unknown as OpencodeClient
|
||||
const session = createServerSession(client)
|
||||
const stale: Session = {
|
||||
id: "ses_stale",
|
||||
slug: "ses_stale",
|
||||
projectID: "project",
|
||||
directory: "/project",
|
||||
title: "stale",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
session.remember(stale)
|
||||
session.set("session_status", stale.id, { type: "busy" })
|
||||
|
||||
await bootstrapDirectory({
|
||||
directory: "/project",
|
||||
scope: ServerScope.local,
|
||||
mcp: false,
|
||||
global: {
|
||||
config: {} satisfies Config,
|
||||
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
|
||||
project: [{ id: "project", worktree: "/project" } as Project],
|
||||
provider,
|
||||
},
|
||||
sdk: client,
|
||||
store,
|
||||
setStore,
|
||||
vcsCache: { setStore() {} } as unknown as VcsCache,
|
||||
loadSessions() {},
|
||||
translate: (key) => key,
|
||||
queryClient: new QueryClient(),
|
||||
session,
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 500
|
||||
while (!session.data.session_working("ses_busy") && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
expect(session.data.session_status["ses_busy"]?.type).toBe("busy")
|
||||
expect(session.data.session_status[stale.id]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("query keys", () => {
|
||||
|
||||
@@ -247,25 +247,29 @@ export async function bootstrapDirectory(input: {
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.session.status().then(async (x) => {
|
||||
if (input.session) {
|
||||
const statuses = x.data ?? {}
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
if (!input.session) {
|
||||
input.setStore("session_status", x.data!)
|
||||
return
|
||||
}
|
||||
if (!input.session) input.setStore("session_status", x.data!)
|
||||
const statuses = x.data ?? {}
|
||||
input.session.set(
|
||||
"session_status",
|
||||
produce((draft) => {
|
||||
for (const sessionID of Object.keys(draft)) {
|
||||
if (statuses[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
||||
}
|
||||
}),
|
||||
)
|
||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
||||
input.session.set("session_status", sessionID, reconcile(status))
|
||||
}
|
||||
// Warm session info only after seeding statuses so a stalled session
|
||||
// fetch cannot park busy indicators behind it, mirroring how live
|
||||
// session.status events apply first and resolve info in the background.
|
||||
await Promise.all(
|
||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
!seededProject &&
|
||||
|
||||
@@ -64,6 +64,9 @@ type PlatformBase = {
|
||||
/** Storage mechanism, defaults to localStorage */
|
||||
storage?: (name?: string) => SyncStorage | AsyncStorage
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
|
||||
@@ -235,7 +236,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
if (pending) return pending
|
||||
const active = generation(sessionID)
|
||||
const request = client.session.get({ sessionID }).then((result) => {
|
||||
if (!result.data) throw new Error(`Session not found: ${sessionID}`)
|
||||
if (!result.data) throw sessionNotFoundError(sessionID)
|
||||
if (generations.get(sessionID) !== active) return result.data
|
||||
return remember(result.data)
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const fallback = server.key
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.global("tabs"),
|
||||
...Persist.window("tabs"),
|
||||
migrate: (value: unknown) => {
|
||||
if (!Array.isArray(value)) return value
|
||||
return value.map((tab) => {
|
||||
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -388,6 +388,20 @@ export const dict = {
|
||||
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
|
||||
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
|
||||
"wsl.onboarding.loading": "Loading...",
|
||||
"wsl.onboarding.installedDistros": "Installed distros",
|
||||
"wsl.onboarding.checkAgain": "Check again",
|
||||
"wsl.onboarding.distroStatus.ready": "Ready",
|
||||
"wsl.onboarding.distroStatus.checking": "Checking...",
|
||||
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
|
||||
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
|
||||
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
|
||||
"wsl.onboarding.needAnotherDistro": "Need another distro?",
|
||||
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
|
||||
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
|
||||
"wsl.onboarding.wslNotInstalled.description":
|
||||
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
|
||||
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
|
||||
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
|
||||
"wsl.onboarding.installWsl": "Install WSL",
|
||||
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
|
||||
"wsl.onboarding.next": "Next",
|
||||
@@ -397,6 +411,7 @@ export const dict = {
|
||||
"wsl.onboarding.install": "Install",
|
||||
"wsl.onboarding.installing": "Installing...",
|
||||
"wsl.onboarding.installDistro": "Install distro",
|
||||
"wsl.onboarding.searchDistros": "Search distros",
|
||||
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
|
||||
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
|
||||
"wsl.onboarding.openTerminal": "Open terminal",
|
||||
@@ -604,7 +619,12 @@ export const dict = {
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.review": "Review",
|
||||
"session.tab.context": "Context",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
"session.panel.reviewAndFiles": "Review and files",
|
||||
"session.error.notFound": "This session cannot be found",
|
||||
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
|
||||
"session.error.notFound.closeTab": "Close Tab",
|
||||
"session.error.serverConnection": "Can't connect to this server",
|
||||
"session.review.filesChanged": "{{count}} Files Changed",
|
||||
"session.review.change.one": "Change",
|
||||
"session.review.change.other": "Changes",
|
||||
@@ -637,6 +657,10 @@ export const dict = {
|
||||
"session.todo.expand": "Expand",
|
||||
"session.todo.progress": "{{done}} of {{total}} todos completed",
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.minimize": "Minimize question",
|
||||
"session.question.restore": "Restore question",
|
||||
"session.question.pending.one": "{{count}} pending question",
|
||||
"session.question.pending.other": "{{count}} pending questions",
|
||||
"session.followupDock.summary.one": "{{count}} queued message",
|
||||
"session.followupDock.summary.other": "{{count}} queued messages",
|
||||
"session.followupDock.sendNow": "Send now",
|
||||
|
||||
@@ -194,8 +194,19 @@
|
||||
timeline-scope: --home-projects-scroll;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"] {
|
||||
timeline-scope: --model-selector-scroll;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"] {
|
||||
timeline-scope: --manage-models-scroll;
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::before,
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
[data-slot="home-projects-scroll"]::after,
|
||||
[data-slot="model-selector-scroll"]::before,
|
||||
[data-slot="model-selector-scroll"]::after,
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -211,11 +222,26 @@
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="home-projects-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
|
||||
[data-slot="home-projects-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --home-projects-scroll y;
|
||||
@@ -233,4 +259,34 @@
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
|
||||
[data-slot="model-selector-scroll"] .scroll-view__viewport {
|
||||
scroll-timeline: --model-selector-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-scroll"]::after {
|
||||
animation: home-projects-fade-bottom linear both;
|
||||
animation-timeline: --model-selector-scroll;
|
||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
||||
}
|
||||
}
|
||||
|
||||
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
|
||||
[data-slot="manage-models-scroll"] .settings-v2-panel {
|
||||
scroll-timeline: --manage-models-scroll y;
|
||||
}
|
||||
|
||||
[data-slot="manage-models-scroll"]::before {
|
||||
animation: home-projects-fade-top linear both;
|
||||
animation-timeline: --manage-models-scroll;
|
||||
animation-range: 0 0.1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"
|
||||
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
|
||||
|
||||
@@ -552,7 +552,6 @@ export function NewHome() {
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
activeServer={selection().server === server.key}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
@@ -612,7 +611,6 @@ export function NewHome() {
|
||||
record={record}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
activeServer={selection().server === server.key}
|
||||
openSession={openSession}
|
||||
archiveSession={archiveSession}
|
||||
/>
|
||||
@@ -671,7 +669,7 @@ function HomeProjectColumn(props: {
|
||||
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
|
||||
aria-label={props.language.t("home.projects")}
|
||||
>
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
|
||||
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
@@ -697,7 +695,7 @@ function HomeProjectColumn(props: {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1 pr-3">
|
||||
<div class="flex min-w-0 flex-col gap-4 pr-3">
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
@@ -996,7 +994,6 @@ function HomeSessionLeading(props: {
|
||||
project: LocalProject
|
||||
session: Session
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
@@ -1006,15 +1003,15 @@ function HomeSessionLeading(props: {
|
||||
<Show when={hasOpenTab()}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 5px)" }}
|
||||
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||
style={{ right: "calc(100% + 4px)" }}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTabAvatar
|
||||
project={props.project}
|
||||
directory={props.session.directory}
|
||||
sessionId={props.session.id}
|
||||
activeServer={props.activeServer}
|
||||
server={props.server}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
/>
|
||||
</div>
|
||||
@@ -1029,7 +1026,6 @@ function HomeSessionSearch(props: {
|
||||
results: HomeSessionRecord[]
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
noResultsLabel: string
|
||||
bindFocus: (focus: () => void) => void
|
||||
onInput: (value: string) => void
|
||||
@@ -1147,7 +1143,6 @@ function HomeSessionSearch(props: {
|
||||
record={record}
|
||||
showProjectName={props.showProjectName}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
@@ -1163,13 +1158,7 @@ function HomeSessionSearch(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<label
|
||||
class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out"
|
||||
classList={{
|
||||
"focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]": !props.open,
|
||||
"shadow-[0_0_0_0.5px_var(--v2-border-border-focus)]": props.open,
|
||||
}}
|
||||
>
|
||||
<label class="relative z-20 flex h-9 w-full items-center gap-2 rounded-[6px] bg-v2-background-bg-layer-02/60 py-1 pl-3 pr-2 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out hover:bg-v2-background-bg-layer-02">
|
||||
<IconV2 name="magnifying-glass" />
|
||||
<input
|
||||
ref={input}
|
||||
@@ -1234,7 +1223,6 @@ function HomeSessionSearchResultRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
selected: boolean
|
||||
onHighlight: () => void
|
||||
onSelect: (session: Session) => void
|
||||
@@ -1264,7 +1252,6 @@ function HomeSessionSearchResultRow(props: {
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
@@ -1303,7 +1290,6 @@ function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
openSession: (session: Session) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}) {
|
||||
@@ -1326,7 +1312,6 @@ function HomeSessionRow(props: {
|
||||
project={props.record.project}
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
|
||||
|
||||
export function useSessionTabAvatarState(
|
||||
server: Accessor<ServerConnection.Key>,
|
||||
directory: Accessor<string>,
|
||||
sessionId: Accessor<string>,
|
||||
active: Accessor<boolean> = () => true,
|
||||
) {
|
||||
const globalSync = useServerSync()
|
||||
const global = useGlobal()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const sync = createMemo(() => {
|
||||
const conn = connection()
|
||||
if (conn) return global.ensureServerCtx(conn).sync
|
||||
})
|
||||
const hasPermissions = createMemo(() => {
|
||||
if (!active()) return false
|
||||
const [store] = globalSync().child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, globalSync().session.data.permission, sessionId(), (item) => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
|
||||
const unread = createMemo(() => {
|
||||
if (hasPermissions()) return true
|
||||
if (!connection()) return false
|
||||
return notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0
|
||||
})
|
||||
const loading = createMemo(() => {
|
||||
if (!active()) return false
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
if (hasPermissions()) return false
|
||||
return globalSync().session.data.session_working(sessionId())
|
||||
return serverSync.session.data.session_working(sessionId())
|
||||
})
|
||||
return { unread, loading }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { getProjectAvatarVariant } from "@/context/layout"
|
||||
import type { ServerConnection } from "@/context/server"
|
||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
|
||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
@@ -10,12 +11,14 @@ export function SessionTabAvatar(props: {
|
||||
project?: LocalProject
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
server: ServerConnection.Key
|
||||
revealProjectOnHover?: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
|
||||
const state = useSessionTabAvatarState(
|
||||
() => props.server,
|
||||
() => props.directory,
|
||||
() => props.sessionId,
|
||||
)
|
||||
const projectAvatar = () => (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project ?? { worktree: props.directory })}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
batch,
|
||||
ErrorBoundary,
|
||||
onCleanup,
|
||||
Show,
|
||||
Match,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
createComputed,
|
||||
on,
|
||||
onMount,
|
||||
type ParentProps,
|
||||
untrack,
|
||||
} from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -19,29 +21,40 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider, useTerminal } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
@@ -65,6 +78,10 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
@@ -73,10 +90,11 @@ import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionOwnership } from "./session/session-ownership"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
||||
type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
@@ -91,6 +109,11 @@ const sessionViewState = () => ({
|
||||
changes: "git" as ChangeMode,
|
||||
})
|
||||
|
||||
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
|
||||
if (!sessionID) return false
|
||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||
}
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
@@ -114,6 +137,208 @@ async function runPromptRollbackMutation<T, R>(input: {
|
||||
})
|
||||
}
|
||||
|
||||
export function SessionPage() {
|
||||
return (
|
||||
<SessionProviders>
|
||||
<Page />
|
||||
</SessionProviders>
|
||||
)
|
||||
}
|
||||
|
||||
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
|
||||
// remount around the server-scoped providers. Nothing here may key on the
|
||||
// session ID: session tabs on the same server share this route instance, and
|
||||
// workspace-scoped state (terminal, directory providers) lives below.
|
||||
export function TargetSessionRouteContent() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
return (
|
||||
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
|
||||
<ResolvedTargetSessionRoute />
|
||||
</SessionRouteErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteErrorBoundary(
|
||||
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error) =>
|
||||
settings.general.newLayoutDesigns() ? (
|
||||
<SessionRouteFrame padded={props.padded}>
|
||||
<SessionPanelFrame newLayout raised={!!props.sessionID}>
|
||||
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
) : (
|
||||
<ErrorPage error={error} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const displayServer = createMemo(() => {
|
||||
const key = props.serverKey ?? server.key
|
||||
const conn = server.list.find((item) => ServerConnection.key(item) === key)
|
||||
return conn ? serverName(conn) : key
|
||||
})
|
||||
const closeTab = () => {
|
||||
if (!props.sessionID) return
|
||||
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
|
||||
}
|
||||
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
|
||||
return (
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
|
||||
<div class="text-13-regular text-text-weak max-w-md">
|
||||
{language.t("session.error.notFound.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.sessionID}>
|
||||
{(sessionID) => (
|
||||
<div class="max-w-full flex flex-col items-center gap-1">
|
||||
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
|
||||
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
|
||||
{sessionID()}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
|
||||
{language.t("session.error.notFound.closeTab")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <ErrorPage error={props.error} />
|
||||
}
|
||||
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync().session.lineage,
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.directory)
|
||||
const targetDirectory = () => directory()!
|
||||
|
||||
createEffect(() => {
|
||||
const session = current()
|
||||
if (!session) return
|
||||
tabs.addSessionTab({
|
||||
server: serverKey(),
|
||||
sessionId: session.root.id,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||
{/* Non-keyed: closes only while the target's directory is unknown (uncached
|
||||
lineage mid-resolution), which tears down the workspace subtree including
|
||||
the terminal. Same-workspace tab switches keep it open because warm
|
||||
targets resolve synchronously from the sync cache. */}
|
||||
<Show when={directory()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
|
||||
>
|
||||
<SDKProvider directory={targetDirectory}>
|
||||
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||
<TargetSessionPage />
|
||||
</DirectoryDataProvider>
|
||||
</SDKProvider>
|
||||
</Show>
|
||||
</Show>
|
||||
</TargetServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
// Owns the workspace-identity remount. Must not include the session ID in the
|
||||
// key: SessionPage handles session changes reactively, and remounting here
|
||||
// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
|
||||
function TargetSessionPage() {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
return (
|
||||
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||
<SessionPage />
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetServerScopedProviders(
|
||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||
) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</PermissionProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
|
||||
const notification = useNotification()
|
||||
createEffect(() => {
|
||||
const sessionID = props.sessionID?.()
|
||||
if (!notification.ready() || !sessionID) return
|
||||
if (notification.session.unseenCount(sessionID) === 0) return
|
||||
notification.session.markViewed(sessionID)
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
function SessionProviders(props: ParentProps) {
|
||||
return (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<CommentsProvider>{props.children}</CommentsProvider>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": props.newLayout,
|
||||
"bg-background-stronger": !props.newLayout,
|
||||
"rounded-[10px] overflow-hidden": props.newLayout,
|
||||
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -167,6 +392,7 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
|
||||
const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -722,9 +948,11 @@ export default function Page() {
|
||||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
// Only treat explicit scroll keys as potential "user scroll" gestures.
|
||||
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
|
||||
markScrollGesture()
|
||||
const key = scrollKey(event)
|
||||
if (key) {
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
|
||||
markScrollGesture(scroller)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -804,22 +1032,22 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesLabel = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={label}
|
||||
label={changesLabel}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -828,6 +1056,24 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const changesTitleV2 = () => {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={changesLabel}
|
||||
placement="bottom-start"
|
||||
gutter={6}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
@@ -874,6 +1120,16 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const reviewEmptyV2 = () => {
|
||||
if ((store.changes === "git" || store.changes === "branch") && !reviewReady()) {
|
||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
if (store.changes === "turn" && nogit()) {
|
||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||
}
|
||||
return <SessionReviewEmptyChangesV2 />
|
||||
}
|
||||
|
||||
const reviewContent = (input: {
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
@@ -907,6 +1163,66 @@ export default function Page() {
|
||||
</Show>
|
||||
)
|
||||
|
||||
const reviewV2State = createReviewPanelV2State()
|
||||
|
||||
// Getters defer reactive reads to the consuming scope. Eager reads here ran inside
|
||||
// the side panel's Show children and remounted the whole review panel on unrelated
|
||||
// updates such as session switches.
|
||||
const reviewPanelV2Props = () => ({
|
||||
get title() {
|
||||
return changesTitleV2()
|
||||
},
|
||||
get empty() {
|
||||
return reviewEmptyV2()
|
||||
},
|
||||
diffs: reviewDiffs,
|
||||
diffsReady: reviewReady,
|
||||
get activeFile() {
|
||||
return tree.activeDiff
|
||||
},
|
||||
onSelectFile: focusReviewDiff,
|
||||
get diffStyle() {
|
||||
return layout.review.diffStyle()
|
||||
},
|
||||
onDiffStyleChange: layout.review.setDiffStyle,
|
||||
state: reviewV2State,
|
||||
onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }),
|
||||
onLineCommentUpdate: updateCommentInContext,
|
||||
onLineCommentDelete: removeCommentFromContext,
|
||||
get lineCommentActions() {
|
||||
return reviewCommentActions()
|
||||
},
|
||||
get comments() {
|
||||
return comments.all()
|
||||
},
|
||||
get focusedComment() {
|
||||
return comments.focus()
|
||||
},
|
||||
onFocusedCommentChange: (focus: { file: string; id: string } | null) => {
|
||||
// The preview clears the focus once it has opened the comment; persist the
|
||||
// focused file as the active selection so the preview stays on it. Skip
|
||||
// files outside the current diff set (their focus is cleared unhandled).
|
||||
if (!focus) {
|
||||
const current = comments.focus()
|
||||
if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file)
|
||||
}
|
||||
comments.setFocus(focus)
|
||||
},
|
||||
})
|
||||
|
||||
// Latch: defer only the first diff render off the mount critical path. This Page
|
||||
// stays mounted across same-workspace session tab switches, so gating on every
|
||||
// deferRender flip tore down and remounted the whole review pane on tab switch.
|
||||
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
|
||||
|
||||
const reviewPanelV2 = () => (
|
||||
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
|
||||
<Show when={reviewPanelV2Rendered()}>
|
||||
<ReviewPanelV2 {...reviewPanelV2Props()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
<div
|
||||
classList={{
|
||||
@@ -1695,9 +2011,88 @@ export default function Page() {
|
||||
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
const sessionErrorFallback = (error: unknown, reset: () => void) => {
|
||||
createEffect(on(sessionKey, reset, { defer: true }))
|
||||
return <SessionErrorFallback error={error} sessionID={params.id} />
|
||||
}
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
{sessionSync() ?? ""}
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<SessionRouteFrame>
|
||||
<SessionHeader />
|
||||
<div
|
||||
class="flex-1 min-h-0 flex flex-col md:flex-row"
|
||||
@@ -1717,85 +2112,19 @@ export default function Page() {
|
||||
width: sessionPanelWidth(),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
|
||||
"bg-background-stronger": !settings.general.newLayoutDesigns(),
|
||||
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
|
||||
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
|
||||
}}
|
||||
>
|
||||
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
|
||||
{mobileTabs(true)}
|
||||
{settings.general.newLayoutDesigns() ? (
|
||||
<Show when={sessionPanelKey()} keyed>
|
||||
{(_) => (
|
||||
<SessionPanelFrame newLayout raised={!!params.id}>
|
||||
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id && mobileChanges()}>
|
||||
<div class="relative h-full overflow-hidden">
|
||||
{reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
|
||||
header: "px-4 !h-16 !pb-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady() ? params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<MessageTimeline
|
||||
actions={actions}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
onUserScroll={markUserScroll}
|
||||
onHistoryScroll={onHistoryScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
shouldAnchorBottom={() =>
|
||||
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
|
||||
}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
userMessages={visibleUserMessages()}
|
||||
setHistoryAnchor={(handlers) => {
|
||||
captureHistoryAnchor = handlers.capture
|
||||
restoreHistoryAnchor = handlers.restore
|
||||
}}
|
||||
anchor={anchor}
|
||||
setRevealMessage={(fn) => {
|
||||
revealMessage = fn
|
||||
}}
|
||||
setScrollToEnd={(fn) => {
|
||||
scrollToEnd = fn
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView worktree={newSessionWorktree()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
|
||||
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
|
||||
</div>
|
||||
) : (
|
||||
<SessionPanelFrame newLayout={false} raised={!!params.id}>
|
||||
{sessionPanelContent()}
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
@@ -1823,7 +2152,7 @@ export default function Page() {
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
@@ -1832,6 +2161,6 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
</div>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
minimized: false,
|
||||
optionsHeight: 180,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let optionsRef: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
const last = createMemo(() => store.tab >= total() - 1)
|
||||
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const optionsOff = createMemo(() => hidden() > 0.98)
|
||||
|
||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||
const prev = input().trim()
|
||||
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const el = optionsRef
|
||||
if (!el) return
|
||||
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
|
||||
update()
|
||||
createResizeObserver(el, update)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
if (!store.minimized) focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const minimize = () => {
|
||||
if (sending()) return
|
||||
setStore("editing", false)
|
||||
setStore("minimized", true)
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
if (sending()) return
|
||||
setStore("minimized", false)
|
||||
focus(pickFocus())
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-component="session-question-dock">
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
<div data-slot="question-header-actions">
|
||||
<Show when={total() > 1}>
|
||||
<div data-slot="question-progress">
|
||||
<For each={questions()}>
|
||||
{(_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i() === store.tab}
|
||||
data-answered={answered(i())}
|
||||
disabled={sending()}
|
||||
onClick={() => jump(i())}
|
||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
data-component="icon-button"
|
||||
data-icon="chevron-down"
|
||||
data-size="normal"
|
||||
data-variant="ghost"
|
||||
disabled={sending()}
|
||||
style={{ transform: `rotate(${hidden() * 180}deg)` }}
|
||||
onClick={store.minimized ? restore : minimize}
|
||||
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-text" class="overflow-auto">
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options">
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="question-footer-actions">
|
||||
<Show when={store.tab > 0}>
|
||||
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="question-text"
|
||||
style={{
|
||||
display: store.minimized ? "-webkit-box" : undefined,
|
||||
"-webkit-line-clamp": store.minimized ? "3" : undefined,
|
||||
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
|
||||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={!store.minimized}>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
ref={(el) => (optionsRef = el)}
|
||||
data-slot="question-options"
|
||||
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
|
||||
classList={{ "pointer-events-none": hidden() > 0.1 }}
|
||||
style={{
|
||||
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
|
||||
visibility: optionsOff() ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<For each={options()}>
|
||||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { sessionNotFoundError } from "@/utils/server-errors"
|
||||
|
||||
type LineageStore<T> = { peek: (id: string) => T | undefined; resolve: (id: string) => Promise<unknown> }
|
||||
|
||||
type Resolution<T> = { id: string; store: LineageStore<T> } & (
|
||||
| { state: "pending" }
|
||||
| { state: "settled" }
|
||||
| { state: "failed"; failure: unknown }
|
||||
)
|
||||
|
||||
// Reactive session lineage for the target session route, read from the sync store.
|
||||
// All session tabs on a server share one route instance, so the target session ID
|
||||
// changes in place; the effect is only a trigger that starts resolution for the
|
||||
// current target, and each run cancels the previous one through onCleanup so a
|
||||
// late result from an abandoned target is dropped. Resolution is imperative rather
|
||||
// than a resource on purpose: a resource created here would be created inside the
|
||||
// router's navigation transition, and suspending that transition deadlocks the URL
|
||||
// commit and double-mounts the session header portals from the transition's shadow
|
||||
// render.
|
||||
//
|
||||
// The returned accessor is a pure derivation. The sync cache is authoritative, and
|
||||
// status only applies while it matches the current target (store + session ID): on
|
||||
// navigation or store replacement the memo re-evaluates before the trigger runs,
|
||||
// so trusting a previous target's settlement would fabricate a not-found for a
|
||||
// session that simply has not resolved yet. Resolve failures rethrow on read so
|
||||
// the enclosing SessionRouteErrorBoundary renders the scoped session error.
|
||||
export function createSessionLineage<T>(sessionID: () => string, lineage: () => LineageStore<T>) {
|
||||
const cached = createMemo(() => lineage().peek(sessionID()))
|
||||
const [status, setStatus] = createSignal<Resolution<T>>()
|
||||
|
||||
createEffect(
|
||||
on([sessionID, lineage] as const, ([id, store]) => {
|
||||
let stale = false
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
if (cached()) {
|
||||
setStatus({ id, store, state: "settled" })
|
||||
return
|
||||
}
|
||||
setStatus({ id, store, state: "pending" })
|
||||
store
|
||||
.resolve(id)
|
||||
.then(() => {
|
||||
if (!stale) setStatus({ id, store, state: "settled" })
|
||||
})
|
||||
.catch((failure) => {
|
||||
if (!stale) setStatus({ id, store, state: "failed", failure })
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
return createMemo(() => {
|
||||
const id = sessionID()
|
||||
const value = cached()
|
||||
if (value) return value
|
||||
const state = status()
|
||||
if (state?.id !== id || state.store !== lineage()) return undefined
|
||||
if (state.state === "failed") throw state.failure
|
||||
// The viewed session is pinned (DirectoryDataProvider, directory-layout.tsx)
|
||||
// and pinned lineages are exempt from cache pruning, so a lineage missing
|
||||
// after settlement means the session (or an ancestor) was deleted, possibly
|
||||
// by another client. Match the resolve error so the boundary shows the
|
||||
// session not found fallback.
|
||||
if (state.state === "settled") throw sessionNotFoundError(id)
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner, ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
@@ -441,6 +441,16 @@ export function MessageTimeline(props: {
|
||||
},
|
||||
})
|
||||
const resizeItem = virtualizer.resizeItem
|
||||
let resizeAnchorScheduled = false
|
||||
const anchorResizedBottom = () => {
|
||||
if (resizeAnchorScheduled || props.hasScrollGesture()) return
|
||||
resizeAnchorScheduled = true
|
||||
queueMicrotask(() => {
|
||||
resizeAnchorScheduled = false
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
}
|
||||
virtualizer.resizeItem = (index, size) => {
|
||||
const item = virtualizer.measurementsCache[index]
|
||||
const previous = item ? (virtualizer.itemSizeCache.get(item.key) ?? item.size) : undefined
|
||||
@@ -462,9 +472,13 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
}
|
||||
resizeItem(index, size)
|
||||
if (root && props.shouldAnchorBottom()) anchorResizedBottom()
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
|
||||
if (props.shouldAnchorBottom()) return false
|
||||
const first = virtualizer.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
}
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) =>
|
||||
item.end <= instance.getLogicalScrollOffset()
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
@@ -491,24 +505,13 @@ export function MessageTimeline(props: {
|
||||
})
|
||||
})
|
||||
|
||||
let bottomAnchorSessionKey = ""
|
||||
let bottomAnchorFrame: number | undefined
|
||||
|
||||
const maybeAnchorBottom = () => {
|
||||
const key = sessionKey()
|
||||
if (bottomAnchorSessionKey === key) return
|
||||
if (timelineRows().length === 0) return
|
||||
bottomAnchorSessionKey = key
|
||||
if (!props.shouldAnchorBottom()) return
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (!props.shouldAnchorBottom() || props.hasScrollGesture()) return
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
clearPrependAnchor()
|
||||
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
|
||||
bottomAnchorFrame = requestAnimationFrame(() => {
|
||||
bottomAnchorFrame = undefined
|
||||
if (sessionKey() !== key) return
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
virtualizer.scrollToEnd()
|
||||
}
|
||||
|
||||
let measuredSessionKey = sessionKey()
|
||||
@@ -527,7 +530,6 @@ export function MessageTimeline(props: {
|
||||
timelineCache.delete(ownerSessionKey)
|
||||
timelineCache.set(ownerSessionKey, { measurements: virtualizer.takeSnapshot(), toolOpen: { ...toolOpen } })
|
||||
while (timelineCache.size > 16) timelineCache.delete(timelineCache.keys().next().value!)
|
||||
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
|
||||
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
|
||||
if (overscanFrame !== undefined) cancelAnimationFrame(overscanFrame)
|
||||
props.setRevealMessage?.(() => {})
|
||||
@@ -600,6 +602,15 @@ export function MessageTimeline(props: {
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const key = scrollKey(event)
|
||||
if (!key) return
|
||||
if (!isScrollKeyTarget(event.target, key)) return
|
||||
if (scrollKeyOwner(event.currentTarget, event.target, key) !== event.currentTarget) return
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
}
|
||||
|
||||
const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||
if (prependLoading) updatePrependAnchor()
|
||||
props.onScheduleScrollState(event.currentTarget)
|
||||
@@ -976,10 +987,16 @@ export function MessageTimeline(props: {
|
||||
.map((ref) => getMsgPart(ref.messageID, ref.partID))
|
||||
.filter((part): part is ToolPart => part?.type === "tool")
|
||||
})
|
||||
const contextOpenKey = () => `context:${row().group.key}`
|
||||
const open = createMemo(() => {
|
||||
return toolOpen[contextOpenKey()] === true
|
||||
})
|
||||
|
||||
return (
|
||||
<ContextToolGroup
|
||||
parts={parts()}
|
||||
open={open()}
|
||||
onOpenChange={(value) => setToolOpen(contextOpenKey(), value)}
|
||||
busy={
|
||||
workingTurn(row().userMessageID) && lastAssistantGroupKey().get(row().userMessageID) === row().group.key
|
||||
}
|
||||
@@ -1339,6 +1356,7 @@ export function MessageTimeline(props: {
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
@@ -1375,14 +1393,14 @@ export function MessageTimeline(props: {
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="px-2 text-14-medium text-text-weak"
|
||||
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -1395,7 +1413,7 @@ export function MessageTimeline(props: {
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
classList={{
|
||||
"text-14-medium text-text-strong truncate": true,
|
||||
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
|
||||
@@ -1414,7 +1432,7 @@ export function MessageTimeline(props: {
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
classList={{
|
||||
"text-14-medium text-text-strong block": true,
|
||||
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
|
||||
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
|
||||
settings.general.newLayoutDesigns(),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
|
||||
import { reuseTimelineRows } from "./row-reconciliation"
|
||||
import { TimelineRow } from "./timeline-row"
|
||||
|
||||
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
|
||||
new TimelineRow.AssistantPart({
|
||||
userMessageID,
|
||||
group: {
|
||||
key,
|
||||
type: "context",
|
||||
refs: partIDs.map((partID) => ({ messageID: "assistant-1", partID })),
|
||||
} satisfies PartGroup,
|
||||
previousAssistantPart: false,
|
||||
})
|
||||
|
||||
const user = (userMessageID = "user-1") => new TimelineRow.UserMessage({ userMessageID, anchor: true })
|
||||
const keys = (rows: TimelineRow.TimelineRow[]) => rows.map(TimelineRow.key)
|
||||
|
||||
describe("reuseTimelineRows", () => {
|
||||
test.each([
|
||||
{
|
||||
name: "reuses an unchanged context group",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when a member is appended",
|
||||
previous: [context("context:a", ["a"])],
|
||||
rows: [context("context:a", ["a", "b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "preserves the group key when the first member is removed",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "lets only the natural owner retain an old key after a split",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "chooses the earliest prior key when groups merge",
|
||||
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
|
||||
rows: [context("context:b", ["b", "a"])],
|
||||
expected: ["assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reserves an old key for its natural owner when two new groups compete",
|
||||
previous: [context("context:a", ["a", "b"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
|
||||
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "does not reuse context identity across user messages",
|
||||
previous: [context("context:a", ["a", "b"], "user-1")],
|
||||
rows: [context("context:b", ["b"], "user-2")],
|
||||
expected: ["assistant-part:user-2:context:b"],
|
||||
reused: [],
|
||||
},
|
||||
{
|
||||
name: "reuses an unaffected ordinary row",
|
||||
previous: [user()],
|
||||
rows: [user()],
|
||||
expected: ["user-message:user-1"],
|
||||
reused: [[0, 0]],
|
||||
},
|
||||
{
|
||||
name: "does not create accidental key collisions",
|
||||
previous: [context("context:a", ["a", "b", "c"])],
|
||||
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
|
||||
expected: [
|
||||
"assistant-part:user-1:context:b",
|
||||
"assistant-part:user-1:context:a",
|
||||
"assistant-part:user-1:context:c",
|
||||
],
|
||||
reused: [],
|
||||
},
|
||||
])("$name", ({ previous, rows, expected, reused }) => {
|
||||
const result = reuseTimelineRows([...previous], [...rows])
|
||||
|
||||
expect(keys(result)).toEqual([...expected])
|
||||
expect(new Set(keys(result)).size).toBe(result.length)
|
||||
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user