mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96902d1a10 | |||
| e55a91eadd | |||
| 2f8fa6afef | |||
| 5e11cf5fc9 | |||
| f5917726d3 | |||
| eb22768a23 | |||
| 8644db2c2a | |||
| aca42423d3 | |||
| d9b81d2233 | |||
| 8fc65b3038 | |||
| f161c90057 | |||
| 728ae7c949 | |||
| 42fc297d30 | |||
| 43dd33842e | |||
| 3d13b6c5b6 | |||
| 996b05432f | |||
| c53f4cfb09 | |||
| 45a49ae32a | |||
| 238ce304a9 | |||
| cf606660fb | |||
| 759695d87c | |||
| f14724dfb1 | |||
| cc53db4406 | |||
| fa055143ea | |||
| 875b28658f | |||
| 0e022036fb | |||
| f0ae3b9569 | |||
| 3b5837d354 | |||
| e1ff217e44 | |||
| ecda3779fa | |||
| a3e69a967b | |||
| 6359623e24 | |||
| e26473f0bc | |||
| 9b4b36dfd0 | |||
| 100487719b | |||
| 6106cb64c7 | |||
| c786ab92d3 | |||
| c400746dd5 | |||
| e3ce37899d |
@@ -80,6 +80,8 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
working-directory: packages/cli
|
working-directory: packages/cli
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||||
run: |
|
run: |
|
||||||
bun run script/build.ts --single --skip-install
|
bun run script/build.ts --single --skip-install
|
||||||
bun run script/service-smoke.ts
|
bun run script/service-smoke.ts
|
||||||
@@ -94,6 +96,8 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
working-directory: packages/cli
|
working-directory: packages/cli
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
|
||||||
run: |
|
run: |
|
||||||
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
bun run script/build-node.ts --single --skip-install --outdir=dist/node
|
||||||
bun run script/service-smoke.ts --node
|
bun run script/service-smoke.ts --node
|
||||||
|
|||||||
@@ -81,7 +81,6 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@tanstack/solid-virtual": "catalog:",
|
"@tanstack/solid-virtual": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
@@ -189,12 +188,15 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"solid-js": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.101",
|
"effect": "4.0.0-beta.107",
|
||||||
|
"solid-js": ">=1.9.0",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"effect",
|
"effect",
|
||||||
|
"solid-js",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
@@ -518,7 +520,7 @@
|
|||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.18.15",
|
"version": "1.18.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.101",
|
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
@@ -698,6 +700,7 @@
|
|||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
|
"mermaid": "11.16.1",
|
||||||
"morphdom": "2.7.8",
|
"morphdom": "2.7.8",
|
||||||
"motion": "12.34.5",
|
"motion": "12.34.5",
|
||||||
"remeda": "catalog:",
|
"remeda": "catalog:",
|
||||||
@@ -914,7 +917,7 @@
|
|||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"katex": "0.16.27",
|
"katex": "0.16.47",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
@@ -1051,9 +1054,9 @@
|
|||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
|
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||||
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
|
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
|
||||||
@@ -1065,6 +1068,7 @@
|
|||||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"@effect/platform-node-shared": "catalog:",
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
@@ -1075,9 +1079,10 @@
|
|||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@corvu/drawer": "0.2.4",
|
"@corvu/drawer": "0.2.4",
|
||||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||||
"@effect/platform-node": "4.0.0-beta.101",
|
"@effect/platform-node": "4.0.0-beta.107",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||||
|
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
@@ -1114,7 +1119,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.101",
|
"effect": "4.0.0-beta.107",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"get-east-asian-width": "1.6.0",
|
"get-east-asian-width": "1.6.0",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -1561,13 +1566,13 @@
|
|||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||||
|
|
||||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
|
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.107", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-beta.107" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg=="],
|
||||||
|
|
||||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
|
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.107", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.107", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA=="],
|
||||||
|
|
||||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
|
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.107", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA=="],
|
||||||
|
|
||||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
|
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.107", "", { "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-BuSSCUoXz6JSR30wT4Y0ukmJKvIFhm/gROwEeA0nLO5QYf33CdTunwX+q35+/MNOOLT8jL3xaDsz5R5aAF1vYQ=="],
|
||||||
|
|
||||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||||
|
|
||||||
@@ -2953,8 +2958,6 @@
|
|||||||
|
|
||||||
"@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="],
|
"@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="],
|
||||||
|
|
||||||
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
|
|
||||||
|
|
||||||
"@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="],
|
"@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="],
|
||||||
|
|
||||||
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
|
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
|
||||||
@@ -3871,7 +3874,7 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
|
"effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
@@ -4073,8 +4076,6 @@
|
|||||||
|
|
||||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
|
||||||
|
|
||||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
||||||
@@ -4325,7 +4326,7 @@
|
|||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
|
|
||||||
@@ -4523,7 +4524,7 @@
|
|||||||
|
|
||||||
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
||||||
|
|
||||||
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
|
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||||
|
|
||||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
@@ -4833,8 +4834,6 @@
|
|||||||
|
|
||||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||||
|
|
||||||
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
|
|
||||||
|
|
||||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||||
|
|
||||||
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
||||||
@@ -5641,8 +5640,6 @@
|
|||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
|
||||||
|
|
||||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
||||||
|
|
||||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||||
@@ -6247,6 +6244,8 @@
|
|||||||
|
|
||||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"@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-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@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=="],
|
"@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=="],
|
||||||
@@ -6301,10 +6300,6 @@
|
|||||||
|
|
||||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
|
"@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
|
||||||
|
|
||||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
|
||||||
|
|
||||||
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
|
||||||
|
|
||||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||||
|
|
||||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="],
|
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="],
|
||||||
@@ -6793,8 +6788,6 @@
|
|||||||
|
|
||||||
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
|
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
|
||||||
|
|
||||||
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
|
||||||
|
|
||||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||||
|
|
||||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||||
@@ -7817,8 +7810,6 @@
|
|||||||
|
|
||||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||||
|
|
||||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
|
||||||
|
|
||||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"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=="],
|
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|||||||
+7
-5
@@ -37,9 +37,10 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||||
"@effect/platform-node": "4.0.0-beta.101",
|
"@effect/platform-node": "4.0.0-beta.107",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||||
|
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-rc.2",
|
"drizzle-kit": "1.0.0-rc.2",
|
||||||
"drizzle-orm": "1.0.0-rc.2",
|
"drizzle-orm": "1.0.0-rc.2",
|
||||||
"effect": "4.0.0-beta.101",
|
"effect": "4.0.0-beta.107",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.168",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
@@ -154,6 +155,7 @@
|
|||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
"@opentui/keymap": "catalog:",
|
"@opentui/keymap": "catalog:",
|
||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
|
"@effect/platform-node-shared": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:"
|
||||||
@@ -164,6 +166,7 @@
|
|||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
|
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
@@ -171,7 +174,6 @@
|
|||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
|
||||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const GeminiFunctionCallPart = Schema.Struct({
|
|||||||
functionCall: Schema.Struct({
|
functionCall: Schema.Struct({
|
||||||
id: Schema.optional(Schema.String),
|
id: Schema.optional(Schema.String),
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
args: Schema.Unknown,
|
args: Schema.optional(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
thoughtSignature: Schema.optional(Schema.String),
|
thoughtSignature: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
@@ -166,6 +166,7 @@ const GeminiGenerationConfig = Schema.Struct({
|
|||||||
const GeminiBodyFields = {
|
const GeminiBodyFields = {
|
||||||
cachedContent: Schema.optional(Schema.String),
|
cachedContent: Schema.optional(Schema.String),
|
||||||
contents: Schema.Array(GeminiContent),
|
contents: Schema.Array(GeminiContent),
|
||||||
|
labels: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||||
safetySettings: optionalArray(GeminiSafetySetting),
|
safetySettings: optionalArray(GeminiSafetySetting),
|
||||||
serviceTier: Schema.optional(Schema.String),
|
serviceTier: Schema.optional(Schema.String),
|
||||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||||
@@ -190,8 +191,19 @@ const GeminiCandidate = Schema.Struct({
|
|||||||
finishReason: Schema.optional(Schema.String),
|
finishReason: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
blockReason: Schema.optional(Schema.String),
|
||||||
|
blockReasonMessage: Schema.optional(Schema.String),
|
||||||
|
safetyRatings: Schema.optional(Schema.Unknown),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||||
|
|
||||||
const GeminiEvent = Schema.Struct({
|
const GeminiEvent = Schema.Struct({
|
||||||
candidates: optionalArray(GeminiCandidate),
|
candidates: optionalArray(GeminiCandidate),
|
||||||
|
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||||
usageMetadata: Schema.optional(GeminiUsage),
|
usageMetadata: Schema.optional(GeminiUsage),
|
||||||
})
|
})
|
||||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||||
@@ -200,6 +212,7 @@ interface ParserState {
|
|||||||
readonly finishReason?: string
|
readonly finishReason?: string
|
||||||
readonly hasToolCalls: boolean
|
readonly hasToolCalls: boolean
|
||||||
readonly nextToolCallId: number
|
readonly nextToolCallId: number
|
||||||
|
readonly promptFeedback?: GeminiPromptFeedback
|
||||||
readonly usage?: Usage
|
readonly usage?: Usage
|
||||||
readonly lifecycle: Lifecycle.State
|
readonly lifecycle: Lifecycle.State
|
||||||
readonly reasoningSignature?: string
|
readonly reasoningSignature?: string
|
||||||
@@ -503,32 +516,37 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||||
state.finishReason || state.usage
|
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||||
? (() => {
|
const finishReason = state.finishReason ?? promptBlockReason
|
||||||
const events: LLMEvent[] = []
|
if (finishReason === undefined && state.usage === undefined) return []
|
||||||
const lifecycle = state.reasoningSignature
|
|
||||||
? Lifecycle.reasoningEnd(
|
const events: LLMEvent[] = []
|
||||||
state.lifecycle,
|
const lifecycle = state.reasoningSignature
|
||||||
events,
|
? Lifecycle.reasoningEnd(
|
||||||
"reasoning-0",
|
state.lifecycle,
|
||||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
events,
|
||||||
)
|
"reasoning-0",
|
||||||
: state.lifecycle
|
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||||
Lifecycle.finish(lifecycle, events, {
|
)
|
||||||
reason: {
|
: state.lifecycle
|
||||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
Lifecycle.finish(lifecycle, events, {
|
||||||
raw: state.finishReason,
|
reason: {
|
||||||
},
|
normalized:
|
||||||
usage: state.usage,
|
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
|
||||||
})
|
raw: finishReason,
|
||||||
return events
|
},
|
||||||
})()
|
usage: state.usage,
|
||||||
: []
|
providerMetadata:
|
||||||
|
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
const step = (state: ParserState, event: GeminiEvent) => {
|
const step = (state: ParserState, event: GeminiEvent) => {
|
||||||
const nextState = {
|
const nextState = {
|
||||||
...state,
|
...state,
|
||||||
|
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||||
}
|
}
|
||||||
const candidate = event.candidates?.[0]
|
const candidate = event.candidates?.[0]
|
||||||
@@ -569,7 +587,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("functionCall" in part) {
|
if ("functionCall" in part) {
|
||||||
const input = part.functionCall.args
|
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||||
const id = `tool_${nextToolCallId++}`
|
const id = `tool_${nextToolCallId++}`
|
||||||
const metadata = {
|
const metadata = {
|
||||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||||
|
|||||||
@@ -202,14 +202,15 @@ export const errorText = (error: unknown) => {
|
|||||||
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream
|
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream
|
||||||
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
||||||
* `Retry` control event on its error channel; we drop it here (we don't
|
* `Retry` control event on its error channel; we drop it here (we don't
|
||||||
* implement client-driven retries) so the public error channel stays
|
* implement client-driven retries). Decoder failures become provider output
|
||||||
* `AIError`.
|
* errors so the public error channel stays `AIError`.
|
||||||
*/
|
*/
|
||||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
||||||
bytes.pipe(
|
bytes.pipe(
|
||||||
Stream.decodeText(),
|
Stream.decodeText(),
|
||||||
Stream.pipeThroughChannel(Sse.decode()),
|
Stream.pipeThroughChannel(Sse.decode()),
|
||||||
Stream.catchTag("Retry", () => Stream.empty),
|
Stream.catchTag("Retry", () => Stream.empty),
|
||||||
|
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||||
Stream.map((event) => event.data),
|
Stream.map((event) => event.data),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
|||||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||||
|
|
||||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||||
const SERVER_CODES = new Set([
|
const SERVER_CODES = new Set([
|
||||||
"api_error",
|
"api_error",
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
|
import { Effect } from "effect"
|
||||||
import type { ProviderPackage } from "../provider-package.js"
|
import type { ProviderPackage } from "../provider-package.js"
|
||||||
import { Gemini } from "../protocols/gemini.js"
|
import { Gemini } from "../protocols/gemini.js"
|
||||||
|
import { ProviderShared } from "../protocols/shared.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||||
import { Endpoint } from "../route/endpoint.js"
|
import { Endpoint } from "../route/endpoint.js"
|
||||||
import { Framing } from "../route/framing.js"
|
import { Framing } from "../route/framing.js"
|
||||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema/index.js"
|
||||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||||
|
|
||||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
readonly labels?: Readonly<Record<string, string>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GeminiProviderOptionsInput = ProviderOptions & {
|
||||||
|
readonly gemini?: GeminiOptionsInput
|
||||||
|
}
|
||||||
|
|
||||||
export const id = ProviderID.make("google-vertex")
|
export const id = ProviderID.make("google-vertex")
|
||||||
|
|
||||||
@@ -17,7 +24,7 @@ export type Config = RouteDefaultsInput &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
readonly providerOptions?: GeminiProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Settings = ProviderPackage.Settings &
|
export type Settings = ProviderPackage.Settings &
|
||||||
@@ -28,14 +35,33 @@ export type Settings = ProviderPackage.Settings &
|
|||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly location?: string
|
readonly location?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
readonly providerOptions?: GeminiProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||||
|
const body = yield* Gemini.protocol.body.from(request)
|
||||||
|
const value = request.providerOptions?.gemini?.labels
|
||||||
|
const labels = ProviderShared.isRecord(value)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
return { ...body, labels }
|
||||||
|
})
|
||||||
|
|
||||||
|
const protocol = {
|
||||||
|
...Gemini.protocol,
|
||||||
|
body: {
|
||||||
|
...Gemini.protocol.body,
|
||||||
|
from: fromRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const route = Route.make({
|
const route = Route.make({
|
||||||
id: "google-vertex-gemini",
|
id: "google-vertex-gemini",
|
||||||
provider: id,
|
provider: id,
|
||||||
providerMetadataKey: "google",
|
providerMetadataKey: "google",
|
||||||
protocol: Gemini.protocol,
|
protocol,
|
||||||
endpoint: Endpoint.path(({ request }) => {
|
endpoint: Endpoint.path(({ request }) => {
|
||||||
const model = String(request.model.id)
|
const model = String(request.model.id)
|
||||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||||
@@ -78,7 +104,7 @@ export const configure = (input: Config = {}) => {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
model: (modelID: string | ModelID) =>
|
model: (modelID: string | ModelID) =>
|
||||||
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
configuredRoute(input, modelID).model<GeminiProviderOptionsInput>({ id: modelID }),
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,10 +113,7 @@ export const provider = {
|
|||||||
id,
|
id,
|
||||||
configure,
|
configure,
|
||||||
}
|
}
|
||||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
modelID,
|
|
||||||
settings,
|
|
||||||
) => {
|
|
||||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||||
return configure({
|
return configure({
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const AIErrorReason = Schema.Union([
|
|||||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||||
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||||
|
|
||||||
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
|
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||||
module: Schema.String,
|
module: Schema.String,
|
||||||
method: Schema.String,
|
method: Schema.String,
|
||||||
reason: AIErrorReason,
|
reason: AIErrorReason,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Effect.flip,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||||
|
|||||||
@@ -702,6 +702,80 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("leaves unsigned parallel calls unchanged after a signed Gemini 3 call", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: gemini3,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({
|
||||||
|
id: "tool_0",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerMetadata: { google: { thoughtSignature: "parallel_signature" } },
|
||||||
|
}),
|
||||||
|
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||||
|
ToolCallPart.make({ id: "tool_2", name: "lookup", input: { query: "sports" } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "parallel_signature",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||||
|
thoughtSignature: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("adds the validator bypass sentinel to every call in an unsigned Gemini 3 batch", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: gemini3,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } }),
|
||||||
|
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.contents).toEqual([
|
||||||
|
{
|
||||||
|
role: "model",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||||
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||||
|
thoughtSignature: "skip_thought_signature_validator",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents({
|
const body = sseEvents({
|
||||||
@@ -767,6 +841,31 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("defaults omitted function call args to an empty object", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLMRequest.update(request, {
|
||||||
|
tools: [ToolDefinition.make({ name: "ping", description: "Ping", inputSchema: { type: "object" } })],
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents({
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: { role: "model", parts: [{ functionCall: { name: "ping" } }] },
|
||||||
|
finishReason: "STOP",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps tool calls without a finish reason", () =>
|
it.effect("maps tool calls without a finish reason", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
@@ -862,6 +961,51 @@ describe("Gemini route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const blocked = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents({
|
||||||
|
promptFeedback: {
|
||||||
|
blockReason: "FUTURE_SAFETY_REASON",
|
||||||
|
blockReasonMessage: "Prompt blocked",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ promptFeedback: { blockReason: "SAFETY" } },
|
||||||
|
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||||
|
expect(blocked.events.at(-1)).toMatchObject({
|
||||||
|
type: "finish",
|
||||||
|
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
|
||||||
|
providerMetadata: {
|
||||||
|
google: {
|
||||||
|
promptFeedback: {
|
||||||
|
blockReason: "FUTURE_SAFETY_REASON",
|
||||||
|
blockReasonMessage: "Prompt blocked",
|
||||||
|
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
|
||||||
|
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const reasons = [
|
const reasons = [
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ describe("Google Vertex providers", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("adds billing labels to Vertex Gemini requests", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* compileRequest(
|
||||||
|
LLM.request({
|
||||||
|
model: GoogleVertex.configure({
|
||||||
|
accessToken: "vertex-token",
|
||||||
|
project: "vertex-project",
|
||||||
|
providerOptions: {
|
||||||
|
gemini: { labels: { component: "opencode", environment: "test" } },
|
||||||
|
},
|
||||||
|
}).model("gemini-3.5-flash"),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
labels: { component: "opencode", environment: "test" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const model = GoogleVertexMessages.configure({
|
const model = GoogleVertexMessages.configure({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||||
import {
|
import {
|
||||||
@@ -90,6 +90,18 @@ describe("AI.Usage", () => {
|
|||||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("sseFraming maps decoder failures to AI errors", async () => {
|
||||||
|
const error = await Effect.runPromise(
|
||||||
|
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
|
||||||
|
Stream.runCollect,
|
||||||
|
Effect.flip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(AIError)
|
||||||
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||||
|
})
|
||||||
|
|
||||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
textPart,
|
textPart,
|
||||||
@@ -97,13 +98,15 @@ test.describe("timeline adverse visual stability", () => {
|
|||||||
element.scrollTop = 0
|
element.scrollTop = 0
|
||||||
})
|
})
|
||||||
await page.waitForTimeout(300)
|
await page.waitForTimeout(300)
|
||||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
const trigger = page.locator(
|
||||||
|
`[data-timeline-part-id="${renderedPartID(targetID)}"] [data-slot="collapsible-trigger"]`,
|
||||||
|
)
|
||||||
await expect(trigger).toBeVisible()
|
await expect(trigger).toBeVisible()
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(targetID)}"]`)).toHaveCount(0)
|
||||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||||
await expect(trigger).toBeVisible()
|
await expect(trigger).toBeVisible()
|
||||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||||
@@ -127,13 +130,16 @@ test.describe("timeline adverse visual stability", () => {
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [
|
await waitForVisualSettle(page, [
|
||||||
`[data-timeline-part-id="${shellID}"]`,
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
`[data-timeline-part-id="${followingID}"]`,
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
])
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -184,10 +190,13 @@ test.describe("timeline adverse visual stability", () => {
|
|||||||
})
|
})
|
||||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
textPart,
|
textPart,
|
||||||
@@ -34,13 +35,16 @@ for (const deviceScaleFactor of [1, 1.25]) {
|
|||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [
|
await waitForVisualSettle(page, [
|
||||||
`[data-timeline-part-id="${shellID}"]`,
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
`[data-timeline-part-id="${followingID}"]`,
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
])
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -71,13 +75,16 @@ for (const reducedMotion of [true]) {
|
|||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [
|
await waitForVisualSettle(page, [
|
||||||
`[data-timeline-part-id="${shellID}"]`,
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
`[data-timeline-part-id="${followingID}"]`,
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
])
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
textPart,
|
textPart,
|
||||||
toolPart,
|
toolPart,
|
||||||
@@ -43,11 +44,17 @@ for (const profile of profiles) {
|
|||||||
settings: { editToolPartsExpanded: true },
|
settings: { editToolPartsExpanded: true },
|
||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
tool: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
textPart,
|
textPart,
|
||||||
toolPart,
|
toolPart,
|
||||||
@@ -35,12 +36,23 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
const trigger = page
|
||||||
|
.locator(`[data-timeline-part-id="${renderedPartID(patchID)}"] [data-slot="collapsible-trigger"]`)
|
||||||
|
.first()
|
||||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
patch: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
const second = patchFile("src/b.ts", "add")
|
const second = patchFile("src/b.ts", "add")
|
||||||
|
|||||||
@@ -17,19 +17,28 @@ describe("timeline fixture validation", () => {
|
|||||||
test("rejects malformed SDK values at runtime", () => {
|
test("rejects malformed SDK values at runtime", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
assistantMessage([], {
|
assistantMessage([], {
|
||||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
error: { type: "APIError", message: 1 } as never,
|
||||||
}),
|
}),
|
||||||
).toThrow()
|
).toThrow()
|
||||||
expect(() =>
|
expect(() =>
|
||||||
validateTimelineEvent({
|
validateTimelineEvent({
|
||||||
directory: "C:/OpenCode/TimelineStability",
|
id: "evt_invalid_status",
|
||||||
payload: {
|
created: 1,
|
||||||
id: "evt_invalid_status",
|
type: "session.status",
|
||||||
type: "session.status",
|
data: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
).toThrow()
|
).toThrow()
|
||||||
|
expect(() => validateTimelineMessages([{ ...userMessage(), id: "invalid" } as never])).toThrow()
|
||||||
|
expect(() => validateTimelineMessages([{ ...userMessage(), time: { created: "invalid" } } as never])).toThrow()
|
||||||
|
expect(() =>
|
||||||
|
validateTimelineMessages([
|
||||||
|
userMessage(),
|
||||||
|
{
|
||||||
|
...assistantMessage(),
|
||||||
|
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
|
||||||
|
} as never,
|
||||||
|
]),
|
||||||
|
).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects duplicate IDs and orphan assistants", () => {
|
test("rejects duplicate IDs and orphan assistants", () => {
|
||||||
@@ -42,8 +51,8 @@ describe("timeline fixture validation", () => {
|
|||||||
test("assigns deterministic event IDs", () => {
|
test("assigns deterministic event IDs", () => {
|
||||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
expect(first.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
import type {
|
||||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
JsonValue,
|
||||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
OpenCodeEvent,
|
||||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
SessionInfo,
|
||||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
SessionMessageAssistant,
|
||||||
|
SessionMessageInfo,
|
||||||
|
SessionMessageUser,
|
||||||
|
SessionStatus,
|
||||||
|
SessionStructuredError,
|
||||||
|
} from "@opencode-ai/client/promise"
|
||||||
|
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||||
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { expect, type Page } from "@playwright/test"
|
import { expect, type Page } from "@playwright/test"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
@@ -18,50 +25,80 @@ export const assistantID = "msg_1001_timeline_assistant"
|
|||||||
export const title = "Timeline visual stability"
|
export const title = "Timeline visual stability"
|
||||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const tokens = { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
|
|
||||||
type Session = SessionInfo
|
type Session = SessionInfo
|
||||||
type GlobalEvent = {
|
type TextSeed = {
|
||||||
directory: string
|
id: string
|
||||||
project?: string
|
type: "text"
|
||||||
workspace?: string
|
text: string
|
||||||
payload: {
|
messageID?: string
|
||||||
id: string
|
}
|
||||||
type: string
|
type FileSeed = {
|
||||||
properties: Record<string, unknown>
|
id: string
|
||||||
}
|
type: "file"
|
||||||
|
mime: string
|
||||||
|
filename?: string
|
||||||
|
url: string
|
||||||
|
source?: { type: string; path?: string; text?: { value: string; start: number; end: number } }
|
||||||
|
}
|
||||||
|
type AgentSeed = {
|
||||||
|
id: string
|
||||||
|
type: "agent"
|
||||||
|
name: string
|
||||||
|
source?: { value: string; start: number; end: number }
|
||||||
|
}
|
||||||
|
type ReasoningSeed = {
|
||||||
|
id: string
|
||||||
|
type: "reasoning"
|
||||||
|
text: string
|
||||||
|
time?: { start: number; end?: number }
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
messageID?: string
|
||||||
|
}
|
||||||
|
type ToolSeed = {
|
||||||
|
id: string
|
||||||
|
type: "tool"
|
||||||
|
callID: string
|
||||||
|
tool: string
|
||||||
|
messageID?: string
|
||||||
|
executed?: boolean
|
||||||
|
providerState?: Record<string, unknown>
|
||||||
|
providerResultState?: Record<string, unknown>
|
||||||
|
state:
|
||||||
|
| { status: "pending"; input: Record<string, unknown>; raw: string }
|
||||||
|
| {
|
||||||
|
status: "running"
|
||||||
|
input: Record<string, unknown>
|
||||||
|
title?: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "completed"
|
||||||
|
input: Record<string, unknown>
|
||||||
|
output: string
|
||||||
|
title: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number; end: number }
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "error"
|
||||||
|
input: Record<string, unknown>
|
||||||
|
error: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number; end: number }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineProperties = {
|
export type TimelineMessage = SessionMessageUser | SessionMessageAssistant
|
||||||
"message.updated": { sessionID: string; info: Message }
|
export type TimelineEvent = OpenCodeEvent | readonly OpenCodeEvent[]
|
||||||
"message.removed": { sessionID: string; messageID: string }
|
export type EventPayload = OpenCodeEvent
|
||||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
export type ToolStatus = ToolSeed["state"]["status"]
|
||||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
|
||||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
? TextSeed | FileSeed | AgentSeed
|
||||||
"session.status": { sessionID: string; status: SessionStatus }
|
: TextSeed | ReasoningSeed | ToolSeed
|
||||||
}
|
|
||||||
type TimelinePayload = {
|
|
||||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
|
||||||
}[keyof TimelineProperties]
|
|
||||||
|
|
||||||
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"
|
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||||
@@ -71,26 +108,19 @@ type ToolOptions<State extends ToolStatus> = State extends "pending"
|
|||||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||||
|
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
type PartRef = { messageID: string; type: "text" | "reasoning" | "tool"; ordinal?: number }
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
const partRefs = new Map<string, PartRef>()
|
||||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
|
||||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
const startedParts = new Set<string>()
|
||||||
const timelineEventSchema = Schema.Union([
|
const toolStates = new Map<string, ToolStatus>()
|
||||||
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
|
let eventSequence = 0
|
||||||
|
let durableSequence = -1
|
||||||
|
|
||||||
export async function setupTimeline(
|
export async function setupTimeline(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: {
|
input: {
|
||||||
messages?: TimelineMessage[]
|
messages?: TimelineMessage[]
|
||||||
currentMessages?: SessionMessageInfo[]
|
sessionMessages?: SessionMessageInfo[]
|
||||||
sessionStatus?: Record<string, SessionStatus>
|
sessionStatus?: Record<string, SessionStatus>
|
||||||
settings?: Record<string, boolean>
|
settings?: Record<string, boolean>
|
||||||
sessions?: Session[]
|
sessions?: Session[]
|
||||||
@@ -103,40 +133,26 @@ export async function setupTimeline(
|
|||||||
seedHistory?: boolean
|
seedHistory?: boolean
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
|
eventSequence = 0
|
||||||
|
durableSequence = -1
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
const messages =
|
const messages =
|
||||||
input.currentMessages ??
|
input.sessionMessages ??
|
||||||
validateTimelineMessages([
|
validateTimelineMessages([
|
||||||
...(input.seedHistory ? historyMessages(18) : []),
|
...(input.seedHistory ? historyMessages(18) : []),
|
||||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||||
])
|
])
|
||||||
const active = messages.findLast((message) =>
|
const active = messages.findLast((message) => message.type === "assistant")
|
||||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
const initialStatus: SessionStatus =
|
||||||
)
|
active?.type === "assistant" && active.time.completed === undefined ? { type: "busy" } : { type: "idle" }
|
||||||
const initialStatus = decodeStatus(
|
const transport = await installSseTransport(page, { server, retry: input.eventRetry ?? 20 })
|
||||||
active &&
|
|
||||||
("info" in active
|
|
||||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
|
||||||
: active.type === "assistant" && active.time.completed === undefined)
|
|
||||||
? { type: "busy" }
|
|
||||||
: { type: "idle" },
|
|
||||||
decodeOptions,
|
|
||||||
)
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
const transport = await installSseTransport<EventPayload>(page, {
|
|
||||||
server,
|
|
||||||
retry: input.eventRetry ?? 20,
|
|
||||||
})
|
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
sessions,
|
sessions,
|
||||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
||||||
pageMessages: () => ({
|
pageMessages: () => ({ items: messages }),
|
||||||
items: messages,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
await page.addInitScript((settings) => {
|
await page.addInitScript((settings) => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -151,9 +167,6 @@ export async function setupTimeline(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (settings.newLayoutDesigns === false) {
|
|
||||||
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
|
||||||
}
|
|
||||||
}, input.settings ?? {})
|
}, input.settings ?? {})
|
||||||
if (input.locale) {
|
if (input.locale) {
|
||||||
await page.addInitScript((locale) => {
|
await page.addInitScript((locale) => {
|
||||||
@@ -172,7 +185,7 @@ export async function setupTimeline(
|
|||||||
mobile: false,
|
mobile: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionReady(page, { server, sessionID, title })
|
await expectSessionReady(page, { server, sessionID, title })
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
if (input.cpuRate && input.cpuRate > 1) {
|
if (input.cpuRate && input.cpuRate > 1) {
|
||||||
@@ -182,15 +195,25 @@ export async function setupTimeline(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
transport,
|
transport,
|
||||||
async send(event: TimelineEvent, delay = 0) {
|
async send(input: TimelineEvent, delay = 0) {
|
||||||
const valid = validateTimelineEvent(event)
|
const events = timelineEvents(input)
|
||||||
await transport.send(valid, { marker: describeEvent(valid) })
|
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||||
|
if (events.length > 1)
|
||||||
|
await transport.burst(
|
||||||
|
events,
|
||||||
|
events.map((item) => ({ marker: describeEvent(item) })),
|
||||||
|
)
|
||||||
if (delay) await page.waitForTimeout(delay)
|
if (delay) await page.waitForTimeout(delay)
|
||||||
},
|
},
|
||||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||||
for (const item of sequence) {
|
for (const item of sequence) {
|
||||||
const valid = validateTimelineEvent(item.event)
|
const events = timelineEvents(item.event)
|
||||||
await transport.send(valid, { marker: describeEvent(valid) })
|
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||||
|
if (events.length > 1)
|
||||||
|
await transport.burst(
|
||||||
|
events,
|
||||||
|
events.map((event) => ({ marker: describeEvent(event) })),
|
||||||
|
)
|
||||||
await page.waitForTimeout(item.delay)
|
await page.waitForTimeout(item.delay)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -210,72 +233,63 @@ export async function setupTimeline(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
async waitForPart(partID: string) {
|
async waitForPart(partID: string) {
|
||||||
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`)
|
||||||
await expect(part).toHaveCount(1)
|
await expect(part).toHaveCount(1)
|
||||||
await expect(part).toBeVisible()
|
await expect(part).toBeVisible()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeEvent(event: EventPayload) {
|
function timelineEvents(input: TimelineEvent) {
|
||||||
if (event.payload.type === "message.part.updated") {
|
return (Array.isArray(input) ? input : [input]).map(validateTimelineEvent)
|
||||||
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"]>(
|
function describeEvent(event: OpenCodeEvent) {
|
||||||
type: Type,
|
if (event.type.startsWith("session.tool.")) {
|
||||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
const data = event.data as { id?: string }
|
||||||
): TimelineEvent
|
return [event.type, data.id].filter(Boolean).join(":")
|
||||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
}
|
||||||
return validateTimelineEvent({
|
return event.type
|
||||||
directory,
|
|
||||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
export function event(
|
||||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
type: "session.status",
|
||||||
|
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
|
||||||
|
): OpenCodeEvent {
|
||||||
|
return makeEvent(type, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
|
||||||
|
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
|
||||||
|
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
|
||||||
|
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === input.type)
|
||||||
|
if (!definition) throw new Error(`Unknown timeline event: ${input.type}`)
|
||||||
|
return Schema.decodeUnknownSync(definition)(input) as OpenCodeEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
const messages = input.map((message): TimelineMessage => {
|
||||||
const messages = [...input]
|
const decoded = Schema.decodeUnknownSync(SessionMessage.Info)(message)
|
||||||
|
if (decoded.type !== "user" && decoded.type !== "assistant")
|
||||||
|
throw new Error(`Unsupported timeline message type: ${decoded.type}`)
|
||||||
|
return message
|
||||||
|
})
|
||||||
const messageIDs = new Set<string>()
|
const messageIDs = new Set<string>()
|
||||||
const partIDs = new Set<string>()
|
let parentID: string | undefined
|
||||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
|
||||||
|
|
||||||
messages.forEach((message) => {
|
messages.forEach((message) => {
|
||||||
if (messageIDs.has(message.info.id))
|
if (messageIDs.has(message.id)) throw new Error(`Timeline fixture has duplicate message ID: ${message.id}`)
|
||||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
messageIDs.add(message.id)
|
||||||
messageIDs.add(message.info.id)
|
if (message.type === "user") parentID = message.id
|
||||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
if (message.type === "assistant") {
|
||||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
const expected = typeof message.metadata?.parentID === "string" ? message.metadata.parentID : parentID
|
||||||
message.parts.forEach((part) => {
|
if (!expected || expected !== parentID)
|
||||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
throw new Error(`Timeline assistant ${message.id} must reference a parent user in the fixture`)
|
||||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
message.content.forEach((part) => {
|
||||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
if (part.type !== "tool") return
|
||||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
if (partRefs.has(part.id) && partRefs.get(part.id)?.messageID !== message.id)
|
||||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||||
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
|
return messages
|
||||||
}
|
}
|
||||||
@@ -337,55 +351,137 @@ export function historyMessages(count: number): TimelineMessage[] {
|
|||||||
}).flat()
|
}).flat()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent[] {
|
||||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
const messageID = part.messageID ?? assistantID
|
||||||
decodePart(owned, decodeOptions)
|
const started = startedParts.has(part.id)
|
||||||
return event("message.part.updated", {
|
const ref = partRef(part.id, messageID, part.type)
|
||||||
sessionID,
|
if (part.type === "text") {
|
||||||
part: owned,
|
startedParts.add(part.id)
|
||||||
time: 1700000002000,
|
return [
|
||||||
})
|
...(started
|
||||||
|
? []
|
||||||
|
: [makeEvent("session.text.started", { sessionID, assistantMessageID: messageID, ordinal: ref.ordinal! })]),
|
||||||
|
makeEvent("session.text.ended", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
ordinal: ref.ordinal!,
|
||||||
|
text: part.text,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
startedParts.add(part.id)
|
||||||
|
return [
|
||||||
|
...(started
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
makeEvent("session.reasoning.started", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
ordinal: ref.ordinal!,
|
||||||
|
state: jsonRecord(part.metadata),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
makeEvent("session.reasoning.ended", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
ordinal: ref.ordinal!,
|
||||||
|
text: part.text,
|
||||||
|
state: jsonRecord(part.metadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return toolEvents(part, messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderedPartID(partID: string) {
|
||||||
|
const ref = partRefs.get(partID)
|
||||||
|
if (!ref || ref.type === "tool") return partID
|
||||||
|
return `${ref.messageID}:${ref.type}:${ref.ordinal}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
const ref = partRefs.get(partID)
|
||||||
|
if (!ref || ref.type !== "text" || ref.ordinal === undefined) throw new Error(`Unknown text part: ${partID}`)
|
||||||
|
return makeEvent("session.text.delta", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
ordinal: ref.ordinal,
|
||||||
|
delta,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageUpdated(info: Message) {
|
export function messageUpdated(info: SessionMessageAssistant) {
|
||||||
return event("message.updated", { sessionID, info })
|
if (info.error)
|
||||||
|
return makeEvent("session.step.failed", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: info.id,
|
||||||
|
error: info.error,
|
||||||
|
cost: info.cost,
|
||||||
|
tokens: info.tokens,
|
||||||
|
})
|
||||||
|
return makeEvent("session.step.ended", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: info.id,
|
||||||
|
finish: info.finish ?? "stop",
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
tokens: info.tokens ?? tokens,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||||
return event("session.status", {
|
if (type === "busy") return makeEvent("session.execution.started", { sessionID })
|
||||||
|
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
|
||||||
|
return makeEvent("session.retry.scheduled", {
|
||||||
sessionID,
|
sessionID,
|
||||||
status: type === "retry" ? { type, attempt, message: "Rate limited", next: 1700000010000 } : { type },
|
assistantMessageID: assistantID,
|
||||||
|
attempt,
|
||||||
|
at: 1700000010000,
|
||||||
|
error: { type: "provider.error", message: "Rate limited" },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function userMessage(
|
export function userMessage(
|
||||||
parts?: PartSeed<"user">[],
|
parts?: PartSeed<"user">[],
|
||||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
input: { id?: string; summary?: unknown; created?: number } = {},
|
||||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
): SessionMessageUser {
|
||||||
const id = input.id ?? userID
|
const id = input.id ?? userID
|
||||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||||
const message = {
|
return {
|
||||||
info: {
|
id,
|
||||||
id,
|
type: "user",
|
||||||
sessionID,
|
time: { created: input.created ?? 1700000000000 },
|
||||||
role: "user",
|
text: seeds.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||||
time: { created: input.created ?? 1700000000000 },
|
files: seeds.flatMap((part) => {
|
||||||
summary: input.summary ?? { diffs: [] },
|
if (part.type !== "file") return []
|
||||||
agent: "build",
|
const mention = part.source?.text
|
||||||
model,
|
? { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }
|
||||||
},
|
: undefined
|
||||||
parts: seeds.map((part) => ({
|
return [
|
||||||
...part,
|
{
|
||||||
sessionID,
|
data: part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "",
|
||||||
messageID: id,
|
mime: part.mime,
|
||||||
})),
|
source: part.url.startsWith("data:")
|
||||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
? ({ type: "inline" } as const)
|
||||||
decodeMessage(message, decodeOptions)
|
: ({ type: "uri", uri: part.source?.path ?? part.url } as const),
|
||||||
return message
|
...(part.filename ? { name: part.filename } : {}),
|
||||||
|
...(mention ? { mention } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
agents: seeds.flatMap((part) => {
|
||||||
|
if (part.type !== "agent") return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: part.name,
|
||||||
|
...(part.source
|
||||||
|
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assistantMessage(
|
export function assistantMessage(
|
||||||
@@ -394,49 +490,43 @@ export function assistantMessage(
|
|||||||
id?: string
|
id?: string
|
||||||
parentID?: string
|
parentID?: string
|
||||||
completed?: boolean
|
completed?: boolean
|
||||||
error?: AssistantMessage["error"]
|
error?: SessionStructuredError
|
||||||
created?: number
|
created?: number
|
||||||
} = {},
|
} = {},
|
||||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
): SessionMessageAssistant {
|
||||||
|
if (input.error && (typeof input.error.type !== "string" || typeof input.error.message !== "string"))
|
||||||
|
throw new Error("Invalid assistant error")
|
||||||
const id = input.id ?? assistantID
|
const id = input.id ?? assistantID
|
||||||
const message = {
|
const created = input.created ?? 1700000001000
|
||||||
info: {
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
id,
|
const content = parts.map((part) => messageContent(part, id, ordinals))
|
||||||
sessionID,
|
nextOrdinals.set(id, ordinals)
|
||||||
role: "assistant",
|
return {
|
||||||
time: {
|
id,
|
||||||
created: input.created ?? 1700000001000,
|
type: "assistant",
|
||||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
metadata: { parentID: input.parentID ?? userID },
|
||||||
},
|
time: { created, ...(input.completed === false ? {} : { completed: created + 1_000 }) },
|
||||||
parentID: input.parentID ?? userID,
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
modelID: model.modelID,
|
agent: "build",
|
||||||
providerID: model.providerID,
|
content,
|
||||||
mode: "build",
|
cost: 0.01,
|
||||||
agent: "build",
|
tokens,
|
||||||
path: { cwd: directory, root: directory },
|
...(input.completed === false ? {} : { finish: "stop" as const }),
|
||||||
cost: 0.01,
|
...(input.error ? { error: input.error } : {}),
|
||||||
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(
|
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
|
||||||
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 }
|
return { id: "prt_user_text", type: "text", text, ...input }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
export function textPart(id: string, text: string): TextSeed {
|
||||||
|
partRef(id, assistantID, "text")
|
||||||
return { id, type: "text", text }
|
return { id, type: "text", text }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
export function reasoningPart(id: string, text: string): ReasoningSeed {
|
||||||
|
partRef(id, assistantID, "reasoning")
|
||||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,35 +536,35 @@ export function toolPart(
|
|||||||
state: "pending",
|
state: "pending",
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options?: ToolOptions<"pending">,
|
options?: ToolOptions<"pending">,
|
||||||
): Omit<ToolPart, "sessionID" | "messageID">
|
): ToolSeed
|
||||||
export function toolPart(
|
export function toolPart(
|
||||||
id: string,
|
id: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
state: "running",
|
state: "running",
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options?: ToolOptions<"running">,
|
options?: ToolOptions<"running">,
|
||||||
): Omit<ToolPart, "sessionID" | "messageID">
|
): ToolSeed
|
||||||
export function toolPart(
|
export function toolPart(
|
||||||
id: string,
|
id: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
state: "completed",
|
state: "completed",
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options?: ToolOptions<"completed">,
|
options?: ToolOptions<"completed">,
|
||||||
): Omit<ToolPart, "sessionID" | "messageID">
|
): ToolSeed
|
||||||
export function toolPart(
|
export function toolPart(
|
||||||
id: string,
|
id: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
state: "error",
|
state: "error",
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options?: ToolOptions<"error">,
|
options?: ToolOptions<"error">,
|
||||||
): Omit<ToolPart, "sessionID" | "messageID">
|
): ToolSeed
|
||||||
export function toolPart(
|
export function toolPart(
|
||||||
id: string,
|
id: string,
|
||||||
tool: string,
|
tool: string,
|
||||||
state: ToolStatus,
|
state: ToolStatus,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
options: ToolOptions<ToolStatus> = {},
|
options: ToolOptions<ToolStatus> = {},
|
||||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
): ToolSeed {
|
||||||
const base = { id, type: "tool" as const, callID: id, tool }
|
const base = { id, type: "tool" as const, callID: id, tool }
|
||||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||||
if (state === "running")
|
if (state === "running")
|
||||||
@@ -512,12 +602,7 @@ export function toolPart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shell(
|
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
|
||||||
id: string,
|
|
||||||
state: ToolStatus,
|
|
||||||
output = "",
|
|
||||||
command = `echo ${id}`,
|
|
||||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
|
||||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||||
if (state === "running")
|
if (state === "running")
|
||||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||||
@@ -526,7 +611,7 @@ export function shell(
|
|||||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
|
||||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,16 +639,200 @@ export function session(input: Partial<Session> = {}): Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventSchema<
|
function messageContent(
|
||||||
const Type extends TimelinePayload["type"],
|
part: PartSeed<"assistant">,
|
||||||
const Properties extends Schema.Codec<unknown, unknown>,
|
messageID: string,
|
||||||
>(type: Type, properties: Properties) {
|
ordinals: { text: number; reasoning: number },
|
||||||
return Schema.Struct({
|
): SessionMessageAssistant["content"][number] {
|
||||||
directory: Schema.String,
|
if (part.type === "tool") {
|
||||||
project: Schema.optional(Schema.String),
|
partRefs.set(part.id, { messageID, type: part.type })
|
||||||
workspace: Schema.optional(Schema.String),
|
toolStates.set(part.callID, part.state.status)
|
||||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
} else {
|
||||||
})
|
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
|
||||||
|
startedParts.add(part.id)
|
||||||
|
}
|
||||||
|
if (part.type === "text") return { type: "text", text: part.text }
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning",
|
||||||
|
text: part.text,
|
||||||
|
state: jsonRecord(part.metadata),
|
||||||
|
time: part.time
|
||||||
|
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
const state = part.state
|
||||||
|
const time = "time" in state ? state.time : undefined
|
||||||
|
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
|
||||||
|
const base = {
|
||||||
|
type: "tool" as const,
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
time: {
|
||||||
|
created: time?.start ?? 1700000001000,
|
||||||
|
...(time?.start === undefined ? {} : { ran: time.start }),
|
||||||
|
...(completed === undefined ? {} : { completed }),
|
||||||
|
},
|
||||||
|
...(part.executed === undefined ? {} : { executed: part.executed }),
|
||||||
|
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||||
|
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||||
|
}
|
||||||
|
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
|
||||||
|
if (state.status === "running")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
|
||||||
|
}
|
||||||
|
if (state.status === "error")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: {
|
||||||
|
status: "error",
|
||||||
|
input: jsonRecord(state.input),
|
||||||
|
error: { type: "ToolError", message: state.error },
|
||||||
|
metadata: jsonRecord(state.metadata),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: jsonRecord(state.input),
|
||||||
|
content: [{ type: "text", text: state.output }],
|
||||||
|
metadata: jsonRecord(state.metadata),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
|
||||||
|
const previous = toolStates.get(part.callID)
|
||||||
|
if (previous === "completed" || previous === "error") return []
|
||||||
|
|
||||||
|
const events: OpenCodeEvent[] = []
|
||||||
|
if (!previous) {
|
||||||
|
events.push(
|
||||||
|
makeEvent("session.tool.input.started", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (part.state.status === "pending") {
|
||||||
|
toolStates.set(part.callID, part.state.status)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
if (!previous || previous === "pending") {
|
||||||
|
events.push(
|
||||||
|
makeEvent("session.tool.input.ended", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
text: JSON.stringify(part.state.input),
|
||||||
|
}),
|
||||||
|
makeEvent("session.tool.called", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
input: part.state.input,
|
||||||
|
executed: part.executed ?? true,
|
||||||
|
state: jsonRecord(part.providerState),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (part.state.status === "running") {
|
||||||
|
if (previous === "running" || Object.keys(part.state.metadata).length)
|
||||||
|
events.push(
|
||||||
|
makeEvent("session.tool.progress", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
metadata: jsonRecord(part.state.metadata),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
toolStates.set(part.callID, part.state.status)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
if (part.state.status === "error") {
|
||||||
|
events.push(
|
||||||
|
makeEvent("session.tool.failed", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
error: { type: "ToolError", message: part.state.error },
|
||||||
|
metadata: jsonRecord(part.state.metadata),
|
||||||
|
executed: part.executed ?? true,
|
||||||
|
resultState: jsonRecord(part.providerResultState),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
toolStates.set(part.callID, part.state.status)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
events.push(
|
||||||
|
makeEvent("session.tool.success", {
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: messageID,
|
||||||
|
id: part.callID,
|
||||||
|
content: [{ type: "text", text: part.state.output }],
|
||||||
|
metadata: jsonRecord(part.state.metadata),
|
||||||
|
executed: part.executed ?? true,
|
||||||
|
resultState: jsonRecord(part.providerResultState),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
toolStates.set(part.callID, part.state.status)
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef {
|
||||||
|
const current = partRefs.get(id)
|
||||||
|
if (current) return current
|
||||||
|
if (type === "tool") {
|
||||||
|
const ref = { messageID, type } satisfies PartRef
|
||||||
|
partRefs.set(id, ref)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
const next = nextOrdinals.get(messageID) ?? { text: 0, reasoning: 0 }
|
||||||
|
const ref = { messageID, type, ordinal: next[type]++ } satisfies PartRef
|
||||||
|
nextOrdinals.set(messageID, next)
|
||||||
|
partRefs.set(id, ref)
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvent<Type extends OpenCodeEvent["type"]>(
|
||||||
|
type: Type,
|
||||||
|
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||||
|
): OpenCodeEvent {
|
||||||
|
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
|
||||||
|
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
|
||||||
|
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === type)
|
||||||
|
if (!definition) throw new Error(`Unknown timeline event: ${type}`)
|
||||||
|
const input =
|
||||||
|
definition.durability === "durable"
|
||||||
|
? {
|
||||||
|
...base,
|
||||||
|
durable: { aggregateID: sessionID, seq: ++durableSequence, version: definition.durable.version },
|
||||||
|
}
|
||||||
|
: base
|
||||||
|
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonRecord(value: Record<string, unknown> | undefined): Record<string, JsonValue> {
|
||||||
|
if (!value) return {}
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).flatMap(([key, item]) => {
|
||||||
|
const next = jsonValue(item)
|
||||||
|
return next === undefined ? [] : [[key, next]]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonValue(value: unknown): JsonValue | undefined {
|
||||||
|
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||||
|
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||||
|
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
|
||||||
|
if (!value || typeof value !== "object") return
|
||||||
|
return jsonRecord(value as Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
function provider() {
|
function provider() {
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ import {
|
|||||||
stopVisualProbe,
|
stopVisualProbe,
|
||||||
visualPlan,
|
visualPlan,
|
||||||
} from "../../utils/visual-stability"
|
} from "../../utils/visual-stability"
|
||||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
import {
|
||||||
|
assistantMessage,
|
||||||
|
renderedPartID,
|
||||||
|
setupTimeline,
|
||||||
|
shell,
|
||||||
|
textPart,
|
||||||
|
toolPart,
|
||||||
|
userMessage,
|
||||||
|
waitForVisualSettle,
|
||||||
|
} from "./fixture"
|
||||||
|
|
||||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||||
const shellID = "prt_interaction_01_shell"
|
const shellID = "prt_interaction_01_shell"
|
||||||
@@ -20,11 +29,20 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
const plan = visualPlan(regions, [
|
const plan = visualPlan(regions, [
|
||||||
{ type: "required", regions: ["shell", "following"] },
|
{ type: "required", regions: ["shell", "following"] },
|
||||||
@@ -76,7 +94,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
|||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||||
for (const [name, expanded] of [
|
for (const [name, expanded] of [
|
||||||
["context-expand", true],
|
["context-expand", true],
|
||||||
["context-collapse", false],
|
["context-collapse", false],
|
||||||
@@ -85,7 +103,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
|||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -144,11 +162,22 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
const trigger = page
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
.locator(`[data-timeline-part-id="${renderedPartID(editID)}"] [data-slot="collapsible-trigger"]`)
|
||||||
|
.first()
|
||||||
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
edit: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
@@ -173,64 +202,6 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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`,
|
|
||||||
status: "modified",
|
|
||||||
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) {
|
function lines(count: number) {
|
||||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
partDelta,
|
partDelta,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
reasoningPart,
|
reasoningPart,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
status,
|
status,
|
||||||
@@ -50,7 +51,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
|||||||
prt_shell_long: shellRegion(ids[2]),
|
prt_shell_long: shellRegion(ids[2]),
|
||||||
following: shellRegion(followingID),
|
following: shellRegion(followingID),
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.sendAll([
|
await timeline.sendAll([
|
||||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||||
@@ -60,7 +61,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
|||||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
{ 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]!, "running", lines(50))), delay: 100 },
|
||||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
{ event: messageUpdated(completedAssistantInfo(assistant)), delay: 100 },
|
||||||
{ event: status("idle"), delay: 700 },
|
{ event: status("idle"), delay: 700 },
|
||||||
])
|
])
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
@@ -84,9 +85,11 @@ test.describe("timeline visual lifecycle stability", () => {
|
|||||||
{ perMarker: true },
|
{ perMarker: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
await expect(
|
||||||
|
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`),
|
||||||
|
).toContainText("line 50")
|
||||||
|
|
||||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`)
|
||||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
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 timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||||
@@ -106,26 +109,31 @@ test.describe("timeline visual lifecycle stability", () => {
|
|||||||
})
|
})
|
||||||
await timeline.send(status("busy"), 120)
|
await timeline.send(status("busy"), 120)
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||||
|
const initialReasoning = reasoningPart(reasoningID, "")
|
||||||
|
const initialText = textPart(textID, "Starting")
|
||||||
|
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||||
reasoning: {
|
reasoning: {
|
||||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(reasoningID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
await timeline.send(partUpdated(initialReasoning), 100)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||||
await timeline.waitForPart(reasoningID)
|
await timeline.waitForPart(reasoningID)
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
await timeline.send(partUpdated(initialText), 100)
|
||||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 120)
|
||||||
await timeline.send(status("idle"), 500)
|
await timeline.send(status("idle"), 500)
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
await reportVisualStability(
|
await reportVisualStability(
|
||||||
@@ -144,7 +152,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
|||||||
{ type: "flow", regions: ["reasoning", "text"] },
|
{ type: "flow", regions: ["reasoning", "text"] },
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText("stable output")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -153,5 +161,5 @@ function lines(count: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shellRegion(id: string) {
|
function shellRegion(id: string) {
|
||||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import {
|
|||||||
stopVisualProbe,
|
stopVisualProbe,
|
||||||
visualPlan,
|
visualPlan,
|
||||||
} from "../../utils/visual-stability"
|
} from "../../utils/visual-stability"
|
||||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture"
|
||||||
|
|
||||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||||
const partID = "prt_oracle_ancestor_opacity"
|
const partID = "prt_oracle_ancestor_opacity"
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
content: { selector: `[data-timeline-part-id="${renderedPartID(partID)}"]` },
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await row.evaluate((element) => {
|
await row.evaluate((element) => {
|
||||||
@@ -41,13 +41,13 @@ test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
|||||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||||
const partID = "prt_oracle_descendant_opacity"
|
const partID = "prt_oracle_descendant_opacity"
|
||||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||||
await row.evaluate((element) => {
|
await row.evaluate((element) => {
|
||||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||||
})
|
})
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
content: {
|
content: {
|
||||||
selector: `[data-timeline-part-id="${partID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
textPart,
|
textPart,
|
||||||
@@ -34,8 +35,14 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
|||||||
})
|
})
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||||
@@ -143,8 +150,8 @@ test("tracks keyboard scrolling from a focused timeline descendant", async ({ pa
|
|||||||
reducedMotion: true,
|
reducedMotion: true,
|
||||||
})
|
})
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
const row = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||||
await row.focus()
|
await row.focus()
|
||||||
for (let index = 0; index < 3; index++) {
|
for (let index = 0; index < 3; index++) {
|
||||||
@@ -182,7 +189,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
|||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
const nested = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-scrollable]`)
|
||||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||||
await nested.focus()
|
await nested.focus()
|
||||||
await page.waitForFunction(() => {
|
await page.waitForFunction(() => {
|
||||||
@@ -209,7 +216,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
|||||||
await nested.press("PageUp")
|
await nested.press("PageUp")
|
||||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||||
|
|
||||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
const nonOverflowing = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||||
await nonOverflowing.evaluate((element) => {
|
await nonOverflowing.evaluate((element) => {
|
||||||
element.setAttribute("data-scrollable", "")
|
element.setAttribute("data-scrollable", "")
|
||||||
element.setAttribute("tabindex", "0")
|
element.setAttribute("tabindex", "0")
|
||||||
@@ -238,12 +245,18 @@ test("jump to latest lands on stable final rows after offscreen growth", async (
|
|||||||
)
|
)
|
||||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(followingID)}"]`)).toBeVisible()
|
||||||
await page.waitForTimeout(600)
|
await page.waitForTimeout(600)
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
await reportVisualStability(
|
await reportVisualStability(
|
||||||
@@ -277,8 +290,14 @@ test("handles a single row taller than the viewport", async ({ page }, testInfo)
|
|||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
textPart,
|
textPart,
|
||||||
@@ -68,13 +69,16 @@ for (const profile of profiles) {
|
|||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||||
await waitForVisualSettle(page, [
|
await waitForVisualSettle(page, [
|
||||||
`[data-timeline-part-id="${shellID}"]`,
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
`[data-timeline-part-id="${followingID}"]`,
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
])
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -120,10 +124,19 @@ test("keeps following row stable when a collapsed shell receives 50 lines", asyn
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||||
@@ -164,10 +177,19 @@ test("keeps rows stable when a running shell becomes an error", async ({ page },
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
await waitForVisualSettle(page, [
|
||||||
|
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(
|
await timeline.send(
|
||||||
@@ -215,16 +237,20 @@ test("keeps rows stable when later text arrives before shell output", async ({ p
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
seedHistory: true,
|
seedHistory: true,
|
||||||
})
|
})
|
||||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
const following = textPart(followingID, "Later assistant content arrived before shell output.")
|
||||||
|
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(shellID)}"]`])
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
shell: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
following: {
|
following: {
|
||||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
await timeline.send(partUpdated(following), 240)
|
||||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import {
|
|||||||
partUpdated,
|
partUpdated,
|
||||||
session,
|
session,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
textPart,
|
|
||||||
toolPart,
|
toolPart,
|
||||||
userMessage,
|
userMessage,
|
||||||
} from "./fixture"
|
} from "./fixture"
|
||||||
@@ -27,7 +27,7 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
|||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
})
|
})
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(
|
await timeline.send(
|
||||||
@@ -53,54 +53,3 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
|||||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||||
).toBeVisible()
|
).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 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
partUpdated,
|
partUpdated,
|
||||||
session,
|
session,
|
||||||
sessionID,
|
sessionID,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
status,
|
status,
|
||||||
textPart,
|
textPart,
|
||||||
@@ -48,8 +49,8 @@ test.describe("timeline tool state stability", () => {
|
|||||||
})
|
})
|
||||||
await timeline.send(status("busy"), 120)
|
await timeline.send(status("busy"), 120)
|
||||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
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="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||||
|
|
||||||
const regionIDs = [
|
const regionIDs = [
|
||||||
"prt_state_webfetch",
|
"prt_state_webfetch",
|
||||||
@@ -104,8 +105,10 @@ test.describe("timeline tool state stability", () => {
|
|||||||
{ type: "label-stability", regions: "all" },
|
{ 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="${renderedPartID(questionID)}"]`)).toContainText(
|
||||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
"Keep it stable",
|
||||||
|
)
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||||
await expect(
|
await expect(
|
||||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
@@ -145,7 +148,7 @@ test.describe("timeline tool state stability", () => {
|
|||||||
},
|
},
|
||||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||||
following: {
|
following: {
|
||||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
selector: `[data-timeline-part-id="${renderedPartID("prt_ctx_following")}"]`,
|
||||||
closest: '[data-timeline-row="AssistantPart"]',
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -194,5 +197,5 @@ function questionInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toolRegion(id: string) {
|
function toolRegion(id: string) {
|
||||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
visualPlan,
|
visualPlan,
|
||||||
} from "../../utils/visual-stability"
|
} from "../../utils/visual-stability"
|
||||||
import {
|
import {
|
||||||
assistantID,
|
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
completedAssistantInfo,
|
completedAssistantInfo,
|
||||||
event,
|
event,
|
||||||
messageUpdated,
|
messageUpdated,
|
||||||
partDelta,
|
partDelta,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
status,
|
status,
|
||||||
@@ -22,35 +22,6 @@ import {
|
|||||||
userMessage,
|
userMessage,
|
||||||
} from "./fixture"
|
} 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) => {
|
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||||
const textID = "prt_text_reconcile"
|
const textID = "prt_text_reconcile"
|
||||||
const followingID = "prt_text_reconcile_following"
|
const followingID = "prt_text_reconcile_following"
|
||||||
@@ -59,14 +30,20 @@ test("streams text through growth, canonical replacement, and completion", async
|
|||||||
})
|
})
|
||||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
text: {
|
||||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
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(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(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 500)
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
await reportVisualStability(
|
await reportVisualStability(
|
||||||
testInfo,
|
testInfo,
|
||||||
@@ -105,17 +82,23 @@ test("inserts a completed question between stable rows", async ({ page }, testIn
|
|||||||
],
|
],
|
||||||
cpuRate: 4,
|
cpuRate: 4,
|
||||||
})
|
})
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||||
const regions = defineVisualRegions({
|
const regions = defineVisualRegions({
|
||||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
first: {
|
||||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
selector: `[data-timeline-part-id="${renderedPartID(firstID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
|
last: {
|
||||||
|
selector: `[data-timeline-part-id="${renderedPartID(lastID)}"]`,
|
||||||
|
closest: '[data-timeline-row="AssistantPart"]',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(
|
await timeline.send(
|
||||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||||
600,
|
600,
|
||||||
)
|
)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toBeVisible()
|
||||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||||
})
|
})
|
||||||
@@ -132,8 +115,8 @@ test("replaces thinking with an assistant error without a blank turn", async ({
|
|||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
await timeline.send(
|
await timeline.send(
|
||||||
messageUpdated({
|
messageUpdated({
|
||||||
...assistant.info,
|
...assistant,
|
||||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
error: { type: "APIError", message: "Provider failed visibly" },
|
||||||
}),
|
}),
|
||||||
500,
|
500,
|
||||||
)
|
)
|
||||||
@@ -203,59 +186,6 @@ test("updates retry attempts and long provider messages without remounting the r
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
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(
|
function stablePairPlan(
|
||||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||||
maxPositionReversals: number,
|
maxPositionReversals: number,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ benchmark.describe("performance: review pane scaling", () => {
|
|||||||
await setupTimelineBenchmark(page, {
|
await setupTimelineBenchmark(page, {
|
||||||
historyTurns: 0,
|
historyTurns: 0,
|
||||||
eventBatch: 1,
|
eventBatch: 1,
|
||||||
newLayoutDesigns: true,
|
|
||||||
})
|
})
|
||||||
await page.route("**/vcs/diff**", (route) =>
|
await page.route("**/vcs/diff**", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { expectSessionTitle } from "../../utils/waits"
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
@@ -11,42 +12,35 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
|||||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||||
const userID = "msg_parent_hydration_user"
|
const userID = "msg_parent_hydration_user"
|
||||||
|
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
|
||||||
const user = {
|
const user = {
|
||||||
...fixture.messages[fixture.targetID][0]!,
|
...userSeed,
|
||||||
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
id: userID,
|
||||||
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
time: { created: 1700001000000 },
|
||||||
...part,
|
} satisfies SessionMessageInfo
|
||||||
id: `prt_parent_hydration_user_${index}`,
|
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
|
||||||
messageID: userID,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
|
||||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||||
return {
|
return {
|
||||||
...assistantSeed,
|
...assistantSeed,
|
||||||
info: {
|
id: messageID,
|
||||||
...assistantSeed.info,
|
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||||
id: messageID,
|
content: assistantSeed.content.map((part, partIndex) =>
|
||||||
parentID: userID,
|
part.type === "tool"
|
||||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
|
||||||
},
|
: part,
|
||||||
parts: assistantSeed.parts.map((part, partIndex) => ({
|
),
|
||||||
...part,
|
} satisfies SessionMessageInfo
|
||||||
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
|
||||||
messageID,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
const messages = [user, ...assistants]
|
const messages = [user, ...assistants]
|
||||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
const lastID = userID
|
const lastID = userID
|
||||||
const lastAssistant = assistants.at(-1)!
|
const lastAssistant = assistants.at(-1)!
|
||||||
const lastPart = lastAssistant.parts.at(-1)!
|
const lastPart = lastAssistant.content.at(-1)!
|
||||||
const lastPartID =
|
const lastPartID =
|
||||||
lastPart.type === "tool"
|
lastPart.type === "tool"
|
||||||
? lastPart.id
|
? lastPart.id
|
||||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
: `${lastAssistant.id}:${lastPart.type}:${lastAssistant.content.filter((part) => part.type === lastPart.type).length - 1}`
|
||||||
|
|
||||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(180_000)
|
benchmark.setTimeout(180_000)
|
||||||
@@ -107,30 +101,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
},
|
},
|
||||||
pageMessages: (sessionID, limit, before) => {
|
pageMessages: (sessionID, limit, before) => {
|
||||||
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||||
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
const end = before ? items.findIndex((message) => message.id === before) : items.length
|
||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
await page.route(`**/api/session/${fixture.targetID}`, (route) =>
|
||||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
route.fulfill({
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify(
|
body: JSON.stringify({
|
||||||
current
|
data: {
|
||||||
? {
|
...target,
|
||||||
data: {
|
cost: 0,
|
||||||
...target,
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
cost: 0,
|
location: { directory: target.directory },
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
},
|
||||||
location: { directory: target.directory },
|
}),
|
||||||
},
|
}),
|
||||||
}
|
)
|
||||||
: target,
|
|
||||||
),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
await page.goto(stressSessionHref(fixture.sourceID))
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
@@ -148,8 +137,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
|||||||
{ href, title: target.title },
|
{ href, title: target.title },
|
||||||
)
|
)
|
||||||
const metrics = await measureSessionSwitch(page, {
|
const metrics = await measureSessionSwitch(page, {
|
||||||
destinationIDs: messages.map((message) => message.info.id),
|
destinationIDs: messages.map((message) => message.id),
|
||||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||||
lastID,
|
lastID,
|
||||||
requiredPartID: lastPartID,
|
requiredPartID: lastPartID,
|
||||||
requireBottomAnchor: false,
|
requireBottomAnchor: false,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
|||||||
|
|
||||||
await installCachedRepaintProbe(page, {
|
await installCachedRepaintProbe(page, {
|
||||||
targetHref: stressSessionHref(fixture.targetID),
|
targetHref: stressSessionHref(fixture.targetID),
|
||||||
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
destination: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||||
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
source: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||||
windowMs: 1_000,
|
windowMs: 1_000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,21 +13,8 @@ import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switc
|
|||||||
|
|
||||||
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
type Result = Awaited<ReturnType<typeof measureSessionSwitch>>
|
||||||
|
|
||||||
benchmark("benchmarks cold and hot session tab switching", async ({ browser, report }, testInfo) => {
|
|
||||||
benchmark.setTimeout(180_000)
|
|
||||||
const results = { cold: [] as Result[], hot: [] as Result[] }
|
|
||||||
for (const mode of ["cold", "hot"] as const) {
|
|
||||||
for (let run = 0; run < 5; run++) {
|
|
||||||
results[mode].push(
|
|
||||||
await withBenchmarkPage(browser, `session-tab-switch-${mode}-${run}`, (page) => trial(page, mode), testInfo),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
report({ results, summary: summarize(results) })
|
|
||||||
})
|
|
||||||
|
|
||||||
benchmark(
|
benchmark(
|
||||||
"benchmarks v2 session tab switching with and without the review pane",
|
"benchmarks session tab switching with and without the review pane",
|
||||||
async ({ browser, report }, testInfo) => {
|
async ({ browser, report }, testInfo) => {
|
||||||
benchmark.setTimeout(360_000)
|
benchmark.setTimeout(360_000)
|
||||||
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
|
const runs = Number(process.env.SESSION_TAB_SWITCH_RUNS ?? 5)
|
||||||
@@ -41,8 +28,8 @@ benchmark(
|
|||||||
results[reviewPane][mode].push(
|
results[reviewPane][mode].push(
|
||||||
await withBenchmarkPage(
|
await withBenchmarkPage(
|
||||||
browser,
|
browser,
|
||||||
`session-tab-switch-v2-${reviewPane}-${mode}-${run}`,
|
`session-tab-switch-${reviewPane}-${mode}-${run}`,
|
||||||
(page) => trial(page, mode, { newLayoutDesigns: true, reviewPane }),
|
(page) => trial(page, mode, reviewPane),
|
||||||
testInfo,
|
testInfo,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -53,14 +40,10 @@ benchmark(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async function trial(
|
async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "open") {
|
||||||
page: Page,
|
const reviewDiffs = createReviewDiffs()
|
||||||
mode: "cold" | "hot",
|
|
||||||
options?: { newLayoutDesigns?: boolean; reviewPane?: "closed" | "open" },
|
|
||||||
) {
|
|
||||||
const reviewDiffs = options?.newLayoutDesigns ? createReviewDiffs() : undefined
|
|
||||||
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
|
await mockStressTimeline(page, { vcsDiff: reviewDiffs })
|
||||||
if (options?.newLayoutDesigns) await installTimelineSettings(page)
|
await installTimelineSettings(page)
|
||||||
await installStressSessionTabs(page)
|
await installStressSessionTabs(page)
|
||||||
if (mode === "hot") {
|
if (mode === "hot") {
|
||||||
await page.goto(stressSessionHref(fixture.targetID))
|
await page.goto(stressSessionHref(fixture.targetID))
|
||||||
@@ -72,13 +55,13 @@ async function trial(
|
|||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
}
|
}
|
||||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
if (options?.reviewPane === "open") {
|
if (reviewPane === "open") {
|
||||||
await openReviewPane(page)
|
await openReviewPane(page)
|
||||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
}
|
}
|
||||||
|
|
||||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
|
||||||
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
const href = stressSessionHref(fixture.targetID)
|
const href = stressSessionHref(fixture.targetID)
|
||||||
const result = await measureSessionSwitch(page, {
|
const result = await measureSessionSwitch(page, {
|
||||||
@@ -134,8 +117,6 @@ async function openReviewPane(page: Page) {
|
|||||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
const panel = page.locator("#review-panel")
|
const panel = page.locator("#review-panel")
|
||||||
await expect(panel).toBeVisible()
|
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(() => {
|
await page.waitForFunction(() => {
|
||||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||||
const text = panel?.textContent ?? ""
|
const text = panel?.textContent ?? ""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||||
@@ -10,37 +11,20 @@ const sessionID = "ses_timeline_state_regression"
|
|||||||
const userMessageID = "msg_user_regression"
|
const userMessageID = "msg_user_regression"
|
||||||
const assistantMessageID = "msg_assistant_regression"
|
const assistantMessageID = "msg_assistant_regression"
|
||||||
const editPartID = "prt_0001_edit"
|
const editPartID = "prt_0001_edit"
|
||||||
export const textPartID = "prt_9999_text"
|
export const textPartID = `${assistantMessageID}:text:0`
|
||||||
const title = "Timeline collapse state regression"
|
const title = "Timeline collapse state regression"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
type EventPayload = {
|
type EventPayload = OpenCodeEvent
|
||||||
directory: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
info: {
|
id: userMessageID,
|
||||||
id: userMessageID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 },
|
||||||
role: "user",
|
text: "Please edit the file.",
|
||||||
time: { created: 1700000000000 },
|
} satisfies SessionMessageInfo
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: "prt_user_text",
|
|
||||||
sessionID,
|
|
||||||
messageID: userMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Please edit the file.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const editPart = {
|
const editPart: ToolSeed = {
|
||||||
id: editPartID,
|
id: editPartID,
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: assistantMessageID,
|
messageID: assistantMessageID,
|
||||||
@@ -66,39 +50,22 @@ const editPart = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const streamedTextPart = {
|
|
||||||
id: textPartID,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Streaming added a later assistant text part.",
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
info: {
|
id: assistantMessageID,
|
||||||
id: assistantMessageID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1700000001000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1700000001000 },
|
agent: "build",
|
||||||
parentID: userMessageID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
content: [toolContent(editPart)],
|
||||||
mode: "build",
|
} satisfies SessionMessageInfo
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
},
|
|
||||||
parts: [editPart],
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setupTimelineBenchmark(
|
export async function setupTimelineBenchmark(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: {
|
options: {
|
||||||
historyTurns: number
|
historyTurns: number
|
||||||
eventBatch: number
|
eventBatch: number
|
||||||
newLayoutDesigns?: boolean
|
|
||||||
vcsDiff?: unknown[]
|
vcsDiff?: unknown[]
|
||||||
turnDiffs?: unknown[]
|
turnDiffs?: unknown[]
|
||||||
},
|
},
|
||||||
@@ -106,7 +73,7 @@ export async function setupTimelineBenchmark(
|
|||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
let eventBatch = options.eventBatch
|
let eventBatch = options.eventBatch
|
||||||
const currentUserMessage = options.turnDiffs
|
const currentUserMessage = options.turnDiffs
|
||||||
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
|
||||||
: userMessage
|
: userMessage
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
@@ -124,26 +91,23 @@ export async function setupTimelineBenchmark(
|
|||||||
events: () => events.splice(0, eventBatch),
|
events: () => events.splice(0, eventBatch),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
})
|
})
|
||||||
await page.addInitScript(
|
await page.addInitScript(() => {
|
||||||
(input) => {
|
localStorage.setItem(
|
||||||
localStorage.setItem(
|
"settings.v3",
|
||||||
"settings.v3",
|
JSON.stringify({
|
||||||
JSON.stringify({
|
general: {
|
||||||
general: {
|
editToolPartsExpanded: true,
|
||||||
newLayoutDesigns: input.newLayoutDesigns,
|
shellToolPartsExpanded: true,
|
||||||
editToolPartsExpanded: true,
|
showReasoningSummaries: true,
|
||||||
shellToolPartsExpanded: true,
|
},
|
||||||
showReasoningSummaries: true,
|
}),
|
||||||
},
|
)
|
||||||
}),
|
})
|
||||||
)
|
|
||||||
},
|
|
||||||
{ newLayoutDesigns: options.newLayoutDesigns ?? false },
|
|
||||||
)
|
|
||||||
await page.setViewportSize({ width: 1366, height: 768 })
|
await page.setViewportSize({ width: 1366, height: 768 })
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
await expectAppVisible(scroller)
|
await expectAppVisible(scroller)
|
||||||
return {
|
return {
|
||||||
@@ -187,34 +151,27 @@ export async function setupTimelineBenchmark(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
|
||||||
return {
|
return [
|
||||||
directory,
|
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
|
||||||
payload: {
|
timelineEvent("session.text.delta", {
|
||||||
type: "message.part.updated",
|
sessionID,
|
||||||
properties: {
|
assistantMessageID,
|
||||||
part: {
|
ordinal: 0,
|
||||||
...streamedTextPart,
|
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||||
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
}),
|
||||||
},
|
]
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||||
return Array.from({ length: deltaCount }, (_, index) => ({
|
return Array.from({ length: deltaCount }, (_, index) =>
|
||||||
directory,
|
timelineEvent("session.text.delta", {
|
||||||
payload: {
|
sessionID,
|
||||||
type: "message.part.delta",
|
assistantMessageID,
|
||||||
properties: {
|
ordinal: 0,
|
||||||
messageID: assistantMessageID,
|
delta: streamChunk(index + 1, deltaCount + 1),
|
||||||
partID: textPartID,
|
}),
|
||||||
field: "text",
|
)
|
||||||
delta: streamChunk(index + 1, deltaCount + 1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function performanceTurn(index: number) {
|
function performanceTurn(index: number) {
|
||||||
@@ -320,48 +277,92 @@ function performanceTurn(index: number) {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
]
|
] as unknown as ContentSeed[]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
info: {
|
id: userID,
|
||||||
id: userID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1690000000000 + index * 2_000 },
|
||||||
role: "user",
|
text: `Historical prompt ${index}`,
|
||||||
time: { created: 1690000000000 + index * 2_000 },
|
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: `prt_0000_${suffix}_user`,
|
|
||||||
sessionID,
|
|
||||||
messageID: userID,
|
|
||||||
type: "text",
|
|
||||||
text: `Historical prompt ${index}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
info: {
|
id: assistantID,
|
||||||
id: assistantID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
agent: "build",
|
||||||
parentID: userID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
finish: "stop",
|
||||||
mode: "build",
|
content: parts.map((part) => {
|
||||||
agent: "build",
|
if (part.type === "text") return { type: "text" as const, text: part.text }
|
||||||
path: { cwd: directory, root: directory },
|
if (part.type === "reasoning")
|
||||||
cost: 0.01,
|
return {
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
type: "reasoning" as const,
|
||||||
variant: "max",
|
text: part.text,
|
||||||
finish: "stop",
|
time: { created: part.time.start, completed: part.time.end },
|
||||||
},
|
}
|
||||||
parts,
|
return toolContent(part)
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
]
|
] satisfies SessionMessageInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolSeed = {
|
||||||
|
id?: string
|
||||||
|
sessionID?: string
|
||||||
|
messageID?: string
|
||||||
|
type: "tool"
|
||||||
|
callID: string
|
||||||
|
tool: string
|
||||||
|
state: {
|
||||||
|
status: string
|
||||||
|
input: Record<string, unknown>
|
||||||
|
output: string
|
||||||
|
title?: string
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number; end: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
|
||||||
|
|
||||||
|
type ContentSeed =
|
||||||
|
| (ContentSeedBase & { type: "text"; text: string })
|
||||||
|
| (ContentSeedBase & { type: "reasoning"; text: string; time: { start: number; end: number } })
|
||||||
|
| ToolSeed
|
||||||
|
|
||||||
|
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
|
||||||
|
return {
|
||||||
|
type: "tool",
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: part.state.input as Record<string, JsonValue>,
|
||||||
|
content: [{ type: "text", text: part.state.output }],
|
||||||
|
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSequence = 0
|
||||||
|
|
||||||
|
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
|
||||||
|
type: Type,
|
||||||
|
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||||
|
durable = false,
|
||||||
|
): Extract<OpenCodeEvent, { type: Type }> {
|
||||||
|
eventSequence++
|
||||||
|
return {
|
||||||
|
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||||
|
created: 1700000002000 + eventSequence,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
location: { directory },
|
||||||
|
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
|
||||||
|
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||||
}
|
}
|
||||||
|
|
||||||
function historicalMarkdown(index: number) {
|
function historicalMarkdown(index: number) {
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "./session-timeline-stream-probe"
|
} from "./session-timeline-stream-probe"
|
||||||
|
|
||||||
type TimelineStreamOptions = {
|
type TimelineStreamOptions = {
|
||||||
newLayoutDesigns?: boolean
|
|
||||||
reviewDiffs?: boolean
|
reviewDiffs?: boolean
|
||||||
reviewPane?: boolean
|
reviewPane?: boolean
|
||||||
}
|
}
|
||||||
@@ -45,34 +44,27 @@ benchmark.describe("performance: session timeline streaming", () => {
|
|||||||
report(result.metrics, result.context)
|
report(result.metrics, result.context)
|
||||||
})
|
})
|
||||||
|
|
||||||
benchmark("streams assistant text in v2 with review pane closed", async ({ page, report }) => {
|
benchmark("streams assistant text with review diffs and pane closed", async ({ page, report }) => {
|
||||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true })
|
const result = await runTimelineStreamBenchmark(page, { reviewDiffs: true })
|
||||||
report(result.metrics, result.context)
|
report(result.metrics, result.context)
|
||||||
})
|
})
|
||||||
|
|
||||||
benchmark("streams assistant text in v2 with review diffs and pane closed", async ({ page, report }) => {
|
benchmark("streams assistant text with review pane open", async ({ page, report }) => {
|
||||||
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
benchmark.setTimeout(Number(process.env.TIMELINE_COMPLETION_TIMEOUT_MS ?? 420_000) + 60_000)
|
||||||
const result = await runTimelineStreamBenchmark(page, { newLayoutDesigns: true, reviewDiffs: true })
|
const result = await runTimelineStreamBenchmark(page, { reviewPane: 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)
|
report(result.metrics, result.context)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
benchmark.describe("performance: review pane", () => {
|
benchmark.describe("performance: review pane", () => {
|
||||||
benchmark("loads v2 review diffs and switches active files", async ({ page, report }) => {
|
benchmark("loads review diffs and switches active files", async ({ page, report }) => {
|
||||||
benchmark.setTimeout(240_000)
|
benchmark.setTimeout(240_000)
|
||||||
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
|
const historyTurns = Number(process.env.REVIEW_PANE_HISTORY_TURNS ?? 72)
|
||||||
const diffs = createReviewDiffs()
|
const diffs = createReviewDiffs()
|
||||||
const fixture = await setupTimelineBenchmark(page, {
|
const fixture = await setupTimelineBenchmark(page, {
|
||||||
historyTurns,
|
historyTurns,
|
||||||
eventBatch: 1,
|
eventBatch: 1,
|
||||||
newLayoutDesigns: true,
|
|
||||||
vcsDiff: diffs,
|
vcsDiff: diffs,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -112,7 +104,6 @@ async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOpt
|
|||||||
const fixture = await setupTimelineBenchmark(page, {
|
const fixture = await setupTimelineBenchmark(page, {
|
||||||
historyTurns,
|
historyTurns,
|
||||||
eventBatch,
|
eventBatch,
|
||||||
newLayoutDesigns: options.newLayoutDesigns,
|
|
||||||
// Turn diffs exercise timeline data cost; the pane-open scenario serves the same
|
// 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.
|
// diffs through the default git mode so it works across review implementations.
|
||||||
turnDiffs: options.reviewDiffs ? diffs : undefined,
|
turnDiffs: options.reviewDiffs ? diffs : undefined,
|
||||||
@@ -173,7 +164,6 @@ async function runTimelineStreamBenchmark(page: Page, options: TimelineStreamOpt
|
|||||||
queuedDeltas: deltas.length,
|
queuedDeltas: deltas.length,
|
||||||
historyTurns,
|
historyTurns,
|
||||||
eventBatch,
|
eventBatch,
|
||||||
newLayoutDesigns: options.newLayoutDesigns === true,
|
|
||||||
reviewPane: options.reviewPane === true ? "open" : "closed",
|
reviewPane: options.reviewPane === true ? "open" : "closed",
|
||||||
reviewDiffs: diffs?.length ?? 0,
|
reviewDiffs: diffs?.length ?? 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,9 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
|
|||||||
const projectID = "proj_smoke_timeline"
|
const projectID = "proj_smoke_timeline"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
type MessagePart = {
|
||||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
id: string
|
||||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
type: "text" | "reasoning" | "tool"
|
||||||
|
text?: string
|
||||||
|
time?: { start: number; end?: number }
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
status: "completed"
|
||||||
|
input: Record<string, unknown>
|
||||||
|
output: string
|
||||||
|
title: unknown
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number; end: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function lorem(seed: number, length: number) {
|
function lorem(seed: number, length: number) {
|
||||||
let out = ""
|
let out = ""
|
||||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
|||||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||||
const messageID = id("msg_user", index)
|
const messageID = id("msg_user", index)
|
||||||
return {
|
return {
|
||||||
info: {
|
id: messageID,
|
||||||
id: messageID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
role: "user",
|
text: lorem(index, textLength),
|
||||||
time: { created: 1700000000000 + index * 10_000 },
|
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||||
summary: { diffs },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: id("prt_user_text", index),
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text: lorem(index, textLength),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
function assistantMessage(
|
||||||
|
_sessionID: string,
|
||||||
|
index: number,
|
||||||
|
_parentID: string,
|
||||||
|
parts: MessagePart[],
|
||||||
|
): SessionMessageInfo {
|
||||||
const messageID = id("msg_assistant", index)
|
const messageID = id("msg_assistant", index)
|
||||||
return {
|
return {
|
||||||
info: {
|
id: messageID,
|
||||||
id: messageID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
agent: "build",
|
||||||
parentID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
finish: "stop",
|
||||||
mode: "build",
|
content: parts.map(messageContent),
|
||||||
agent: "build",
|
}
|
||||||
path: { cwd: directory, root: directory },
|
}
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||||
variant: "max",
|
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||||
finish: "stop",
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning",
|
||||||
|
text: part.text ?? "",
|
||||||
|
time: part.time
|
||||||
|
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
const state = part.state!
|
||||||
|
return {
|
||||||
|
type: "tool",
|
||||||
|
id: part.callID ?? part.id,
|
||||||
|
name: part.tool!,
|
||||||
|
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: state.input as Record<string, JsonValue>,
|
||||||
|
content: [{ type: "text", text: state.output }],
|
||||||
|
metadata: state.metadata as Record<string, JsonValue>,
|
||||||
},
|
},
|
||||||
parts: parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +218,7 @@ function code(seed: number, lines: number, width = 32) {
|
|||||||
).join("\n")
|
).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
function turn(index: number): Message[] {
|
function turn(index: number): SessionMessageInfo[] {
|
||||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -241,7 +259,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||||
@@ -267,19 +285,12 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
|||||||
userMessage(childID, index + 2000, 120),
|
userMessage(childID, index + 2000, 120),
|
||||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||||
]).flat()
|
]).flat()
|
||||||
const messages: Record<string, Message[]> = {
|
const messages: Record<string, SessionMessageInfo[]> = {
|
||||||
[sourceID]: sourceMessages,
|
[sourceID]: sourceMessages,
|
||||||
[targetID]: targetMessages,
|
[targetID]: targetMessages,
|
||||||
[childID]: childMessages,
|
[childID]: childMessages,
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
|
||||||
if (part.type === "text") return !!part.text?.trim()
|
|
||||||
if (part.type === "reasoning") return !!part.text?.trim()
|
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fixture = {
|
export const fixture = {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -339,14 +350,19 @@ export const fixture = {
|
|||||||
sourceTitle: "Uncommitted changes inquiry",
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
childTitle: "Inspect child navigation",
|
childTitle: "Inspect child navigation",
|
||||||
sourceMessageIDs: sourceMessages
|
sourceMessageIDs: sourceMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||||
.filter((message) => message.info.role === "user")
|
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||||
.map((message) => message.info.id),
|
childMessageIDs: childMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||||
targetMessageIDs: targetMessages
|
targetPartIDs: targetMessages.flatMap((message) => {
|
||||||
.filter((message) => message.info.role === "user")
|
if (message.type !== "assistant") return []
|
||||||
.map((message) => message.info.id),
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
return message.content.flatMap((part) => {
|
||||||
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||||
|
return [part.id]
|
||||||
|
})
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,12 +371,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
|||||||
const end = before
|
const end = before
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
messages.findIndex((message) => message.info.id === before),
|
messages.findIndex((message) => message.id === before),
|
||||||
)
|
)
|
||||||
: messages.length
|
: messages.length
|
||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return {
|
return {
|
||||||
items: messages.slice(start, end),
|
items: messages.slice(start, end),
|
||||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
cursor: start > 0 ? messages[start].id : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export async function installTimelineSettings(page: Page) {
|
|||||||
"settings.v3",
|
"settings.v3",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
general: {
|
general: {
|
||||||
newLayoutDesigns: true,
|
|
||||||
editToolPartsExpanded: true,
|
editToolPartsExpanded: true,
|
||||||
shellToolPartsExpanded: true,
|
shellToolPartsExpanded: true,
|
||||||
showReasoningSummaries: true,
|
showReasoningSummaries: true,
|
||||||
|
|||||||
@@ -1,121 +1,6 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
|
||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
|
||||||
test("preserves current messages", () => {
|
|
||||||
const message = {
|
|
||||||
id: "msg_current",
|
|
||||||
type: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
text: "current",
|
|
||||||
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
|
|
||||||
} satisfies SessionMessageInfo
|
|
||||||
|
|
||||||
expect(currentMessage(message)).toBe(message)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("converts rich legacy messages to current message types", () => {
|
|
||||||
expect(
|
|
||||||
currentMessage({
|
|
||||||
info: { id: "msg_user", role: "user", time: { created: 1 } },
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: "Use @src/a.ts with @explore" },
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "application/json",
|
|
||||||
filename: "data.json",
|
|
||||||
url: "data:application/json;base64,e30=",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "file",
|
|
||||||
mime: "text/plain",
|
|
||||||
filename: "a.ts",
|
|
||||||
url: "src/a.ts",
|
|
||||||
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
|
|
||||||
},
|
|
||||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
id: "msg_user",
|
|
||||||
type: "user",
|
|
||||||
time: { created: 1 },
|
|
||||||
text: "Use @src/a.ts with @explore",
|
|
||||||
files: [
|
|
||||||
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
|
|
||||||
{
|
|
||||||
data: "",
|
|
||||||
mime: "text/plain",
|
|
||||||
name: "a.ts",
|
|
||||||
source: { type: "uri", uri: "src/a.ts" },
|
|
||||||
mention: { text: "@src/a.ts", start: 4, end: 13 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(
|
|
||||||
currentMessage({
|
|
||||||
info: {
|
|
||||||
id: "msg_assistant",
|
|
||||||
role: "assistant",
|
|
||||||
time: { created: 2, completed: 5 },
|
|
||||||
agent: "explore",
|
|
||||||
modelID: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
variant: "high",
|
|
||||||
cost: 0.5,
|
|
||||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
|
||||||
finish: "tool-calls",
|
|
||||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{ type: "text", text: "Answer" },
|
|
||||||
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
|
|
||||||
{
|
|
||||||
id: "prt_tool",
|
|
||||||
callID: "call_tool",
|
|
||||||
type: "tool",
|
|
||||||
tool: "read",
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { filePath: "src/a.ts" },
|
|
||||||
output: "contents",
|
|
||||||
metadata: { title: "a.ts" },
|
|
||||||
time: { start: 3, end: 4 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
id: "msg_assistant",
|
|
||||||
type: "assistant",
|
|
||||||
time: { created: 2, completed: 5 },
|
|
||||||
agent: "explore",
|
|
||||||
model: { id: "model", providerID: "provider", variant: "high" },
|
|
||||||
cost: 0.5,
|
|
||||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
|
||||||
finish: "tool-calls",
|
|
||||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: "Answer" },
|
|
||||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
|
||||||
{
|
|
||||||
type: "tool",
|
|
||||||
id: "call_tool",
|
|
||||||
name: "read",
|
|
||||||
time: { created: 3, ran: 3, completed: 4 },
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input: { filePath: "src/a.ts" },
|
|
||||||
content: [{ type: "text", text: "contents" }],
|
|
||||||
metadata: { title: "a.ts" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("applies message latency after a list response gate is released", async () => {
|
test("applies message latency after a list response gate is released", async () => {
|
||||||
const events: string[] = []
|
const events: string[] = []
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type VisualStabilityTrace,
|
type VisualStabilityTrace,
|
||||||
} from "../../utils/visual-stability"
|
} from "../../utils/visual-stability"
|
||||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
import { visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||||
|
|
||||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||||
@@ -346,19 +346,6 @@ test("evaluates the typed invariant algebra over explicit observations", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
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(
|
function frame(
|
||||||
at: number,
|
at: number,
|
||||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
await mockServers(page, requests)
|
await mockServers(page, requests)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ serverB, sessionA, sessionB }) => {
|
({ 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.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
@@ -39,31 +38,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
|||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
expect(
|
|
||||||
requests.some((request) => {
|
|
||||||
const url = new URL(request)
|
|
||||||
return url.origin === serverB && url.searchParams.get("directory") === sessionB.directory
|
|
||||||
}),
|
|
||||||
).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
|
||||||
await mockServers(page, [])
|
|
||||||
await page.addInitScript(
|
|
||||||
({ serverB, 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: serverB, sessionId: sessionB }]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ serverB, sessionB: sessionB.id },
|
|
||||||
)
|
|
||||||
|
|
||||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
|
||||||
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function session(id: string, directory: string, title: string) {
|
function session(id: string, directory: string, title: string) {
|
||||||
@@ -93,40 +67,26 @@ async function mockServers(page: Page, requests: string[]) {
|
|||||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (url.pathname === "/api/model/default")
|
||||||
return json(route, [])
|
return json(route, { location: { directory: current.directory }, data: null })
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
if (url.pathname === "/api/mcp/resource")
|
||||||
|
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||||
const project = {
|
const project = {
|
||||||
id: current.projectID,
|
id: current.projectID,
|
||||||
worktree: current.directory,
|
canonical: current.directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
time: { created: 1, updated: 1 },
|
time: { created: 1, updated: 1 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
}
|
}
|
||||||
return json(route, url.pathname === "/project" ? [project] : project)
|
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||||
}
|
}
|
||||||
if (url.pathname === "/path")
|
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||||
return json(route, {
|
|
||||||
state: current.directory,
|
|
||||||
config: current.directory,
|
|
||||||
worktree: current.directory,
|
|
||||||
directory: current.directory,
|
|
||||||
home: current.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/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" })
|
|
||||||
if (url.pathname === "/api/vcs")
|
if (url.pathname === "/api/vcs")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory: current.directory },
|
location: { directory: current.directory },
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ async function setup(page: Page) {
|
|||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessionID }) => {
|
({ directory, server, sessionID }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ test("matches the rounded panel corners to the dark new-session background", asy
|
|||||||
})
|
})
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, draftID, server }) => {
|
({ directory, draftID, server }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||||
localStorage.setItem("opencode-color-scheme", "dark")
|
localStorage.setItem("opencode-color-scheme", "dark")
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ test("expands a folder whose path has a trailing Windows separator", async ({ pa
|
|||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessionID }) => {
|
({ directory, server, sessionID }) => {
|
||||||
localStorage.setItem(
|
|
||||||
"settings.v3",
|
|
||||||
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
|||||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||||
const projectID = "proj_prompt_input_v2_editing"
|
const projectID = "proj_prompt_input_v2_editing"
|
||||||
const sessionID = "ses_prompt_input_v2_editing"
|
const sessionID = "ses_prompt_input_v2_editing"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -32,11 +33,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
await expectAppVisible(composer)
|
await expectAppVisible(composer)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
|||||||
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
||||||
const projectID = "proj_prompt_thinking_level_regression"
|
const projectID = "proj_prompt_thinking_level_regression"
|
||||||
const sessionID = "ses_prompt_thinking_level_regression"
|
const sessionID = "ses_prompt_thinking_level_regression"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
@@ -49,11 +50,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
|
||||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
|||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
const permissionResponses: PermissionResponse[] = []
|
const permissionResponses: PermissionResponse[] = []
|
||||||
await installSseTransport(page, { server: serverB })
|
await installSseTransport(page, { server: serverB })
|
||||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
const transport = await installSseTransport(page, {
|
||||||
server: serverA,
|
server: serverA,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
})
|
})
|
||||||
@@ -82,18 +82,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
|||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
|
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory: directoryA,
|
id: "evt_permission_background_a",
|
||||||
payload: {
|
created: 1700000001000,
|
||||||
id: "event-permission-background-a",
|
type: "permission.asked",
|
||||||
type: "permission.asked",
|
location: { directory: directoryA },
|
||||||
properties: {
|
data: {
|
||||||
id: "permission-background-a",
|
id: "permission-background-a",
|
||||||
sessionID: sessionA.id,
|
sessionID: sessionA.id,
|
||||||
permission: "bash",
|
action: "bash",
|
||||||
patterns: ["git status"],
|
resources: ["git status"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
save: [],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,18 +109,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
|||||||
])
|
])
|
||||||
|
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory: directoryA,
|
id: "evt_permission_background_a_child",
|
||||||
payload: {
|
created: 1700000002000,
|
||||||
id: "event-permission-background-a-child",
|
type: "permission.asked",
|
||||||
type: "permission.asked",
|
location: { directory: directoryA },
|
||||||
properties: {
|
data: {
|
||||||
id: "permission-background-a-child",
|
id: "permission-background-a-child",
|
||||||
sessionID: childSessionA.id,
|
sessionID: childSessionA.id,
|
||||||
permission: "bash",
|
action: "bash",
|
||||||
patterns: ["git diff"],
|
resources: ["git diff"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
save: [],
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -156,7 +154,6 @@ type PermissionResponse = {
|
|||||||
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ serverB, tabs }) => {
|
({ serverB, tabs }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||||
},
|
},
|
||||||
@@ -227,38 +224,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
|||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||||
return json(route, { data: [], cursor: {} })
|
return json(route, { data: [], cursor: {} })
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (url.pathname === "/api/location") return json(route, { directory })
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
|
||||||
if (url.pathname === "/permission") {
|
|
||||||
permissionRequests.push(url.toString())
|
|
||||||
return json(route, [])
|
|
||||||
}
|
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
|
||||||
return json(route, [])
|
|
||||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
|
||||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
|
||||||
const project = {
|
|
||||||
id: remote ? sessionB.projectID : "project-server-a",
|
|
||||||
worktree: 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: directory,
|
|
||||||
config: directory,
|
|
||||||
worktree: directory,
|
|
||||||
directory,
|
|
||||||
home: directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/path")
|
|
||||||
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
|
||||||
if (url.pathname === "/api/vcs")
|
if (url.pathname === "/api/vcs")
|
||||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ test("tab busy indicator reflects the tab server's own session status", async ({
|
|||||||
await mockServers(page)
|
await mockServers(page)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ serverA, serverB, sessionA, sessionB }) => {
|
({ 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.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
@@ -58,47 +57,33 @@ async function mockServers(page: Page) {
|
|||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/api/session/active")
|
if (url.pathname === "/api/session/active")
|
||||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (url.pathname === "/api/model/default")
|
||||||
return json(route, [])
|
return json(route, { location: { directory: current.directory }, data: null })
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
if (url.pathname === "/api/mcp/resource")
|
||||||
|
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||||
const project = {
|
const project = {
|
||||||
id: current.projectID,
|
id: current.projectID,
|
||||||
worktree: current.directory,
|
canonical: current.directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
time: { created: 1, updated: 1 },
|
time: { created: 1, updated: 1 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
}
|
}
|
||||||
return json(route, url.pathname === "/project" ? [project] : project)
|
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||||
}
|
}
|
||||||
if (url.pathname === "/path")
|
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||||
return json(route, {
|
|
||||||
state: current.directory,
|
|
||||||
config: current.directory,
|
|
||||||
worktree: current.directory,
|
|
||||||
directory: current.directory,
|
|
||||||
home: current.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/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" })
|
|
||||||
if (url.pathname === "/api/vcs")
|
if (url.pathname === "/api/vcs")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory: current.directory },
|
location: { directory: current.directory },
|
||||||
@@ -117,10 +102,10 @@ function json(route: Route, body: unknown, status = 200) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route, current: boolean) {
|
function sse(route: Route) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "text/event-stream",
|
contentType: "text/event-stream",
|
||||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
|
||||||
|
|
||||||
const draftID = "draft_removed_layout_preference"
|
|
||||||
const directory = "C:/OpenCode/RemovedLayoutPreference"
|
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
|
|
||||||
test("ignores persisted old layout preferences when opening drafts", async ({ page }) => {
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: "proj_removed_layout_preference",
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "removed-layout-preference",
|
|
||||||
time: { created: 1700000000000, updated: 1700000000000 },
|
|
||||||
sandboxes: [],
|
|
||||||
},
|
|
||||||
provider: { all: [], connected: [], default: {} },
|
|
||||||
sessions: [],
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
})
|
|
||||||
await page.addInitScript(
|
|
||||||
({ directory, draftID, server }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
|
||||||
localStorage.setItem(
|
|
||||||
"opencode.window.browser.dat:tabs",
|
|
||||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, draftID, server },
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/new-session?draftId=${draftID}`)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(`/new-session?draftId=${draftID}`)
|
|
||||||
await expect(page.locator("body")).toHaveAttribute("data-new-layout", "")
|
|
||||||
await expect(page.getByRole("textbox", { name: "Prompt" })).toBeVisible()
|
|
||||||
})
|
|
||||||
@@ -7,6 +7,7 @@ const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
|||||||
const sessionID = "ses_review_image_flash_regression"
|
const sessionID = "ses_review_image_flash_regression"
|
||||||
const title = "Review image flash regression"
|
const title = "Review image flash regression"
|
||||||
const imageFile = "assets/preview.png"
|
const imageFile = "assets/preview.png"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
||||||
await openReview(page)
|
await openReview(page)
|
||||||
@@ -26,9 +27,6 @@ test("clicking an image file in the v2 review pane does not blank the panel", as
|
|||||||
|
|
||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 960, height: 900 })
|
await page.setViewportSize({ width: 960, height: 900 })
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -112,30 +110,16 @@ async function openReview(page: Page) {
|
|||||||
pageMessages: () => ({
|
pageMessages: () => ({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
info: {
|
id: "msg_review_image_flash_regression",
|
||||||
id: "msg_review_image_flash_regression",
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 },
|
||||||
role: "user",
|
text: "Review this change.",
|
||||||
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 page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
|||||||
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
||||||
const sessionID = "ses_review_line_comment_regression"
|
const sessionID = "ses_review_line_comment_regression"
|
||||||
const title = "Review line comment regression"
|
const title = "Review line comment regression"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await openReview(page)
|
await openReview(page)
|
||||||
@@ -88,7 +89,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
|||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
@@ -123,30 +123,16 @@ async function openReview(page: Page) {
|
|||||||
pageMessages: () => ({
|
pageMessages: () => ({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
info: {
|
id: "msg_review_line_comment_regression",
|
||||||
id: "msg_review_line_comment_regression",
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 },
|
||||||
role: "user",
|
text: "Review this change.",
|
||||||
time: { created: 1700000000000 },
|
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: "opencode", modelID: "test" },
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: "prt_review_line_comment_regression",
|
|
||||||
sessionID,
|
|
||||||
messageID: "msg_review_line_comment_regression",
|
|
||||||
type: "text",
|
|
||||||
text: "Review this change.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const changes = page.getByRole("tab", { name: "Changes" })
|
const changes = page.getByRole("tab", { name: "Changes" })
|
||||||
const diffResponse = page.waitForResponse(
|
const diffResponse = page.waitForResponse(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
|||||||
test("opens and searches project files inline", async ({ page }) => {
|
test("opens and searches project files inline", async ({ page }) => {
|
||||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -63,7 +62,6 @@ test("opens and searches project files inline", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessionID }) => {
|
({ directory, server, sessionID }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ async function switchSession(page: Page, title: string) {
|
|||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -103,7 +102,6 @@ async function setup(page: Page) {
|
|||||||
)
|
)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessions }) => {
|
({ directory, server, sessions }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ async function setup(page: Page) {
|
|||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessions }) => {
|
({ directory, server, sessions }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/ReviewTerminalStacked"
|
|||||||
const projectID = "proj_review_terminal_stacked"
|
const projectID = "proj_review_terminal_stacked"
|
||||||
const sessionID = "ses_review_terminal_stacked"
|
const sessionID = "ses_review_terminal_stacked"
|
||||||
const title = "Review terminal stacked"
|
const title = "Review terminal stacked"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
const branchDiffs = [
|
const branchDiffs = [
|
||||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||||
...Array.from({ length: 2_739 }, (_, index) =>
|
...Array.from({ length: 2_739 }, (_, index) =>
|
||||||
@@ -21,7 +22,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -124,15 +124,13 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
|||||||
)
|
)
|
||||||
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:layout",
|
"opencode.global.dat:layout",
|
||||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
|
||||||
await expectSessionReady(page, { server, sessionID, title })
|
await expectSessionReady(page, { server, sessionID, title })
|
||||||
await expect(page.locator("#review-panel")).toBeVisible()
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
await expectTree(page, 2_773, "action.yml")
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ test("shows loaded sessions before the directory path request resolves", async (
|
|||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/api/path?*", async (route) => {
|
await page.route("**/api/location*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const directory = "C:/OpenCode/RequestDocks"
|
|||||||
const projectID = "proj_request_docks"
|
const projectID = "proj_request_docks"
|
||||||
const sessionID = "ses_request_docks"
|
const sessionID = "ses_request_docks"
|
||||||
const title = "Request dock regression"
|
const title = "Request dock regression"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("shows a pending question dock", async ({ page }) => {
|
test("shows a pending question dock", async ({ page }) => {
|
||||||
await mockServer(page, {
|
await mockServer(page, {
|
||||||
@@ -34,7 +35,7 @@ test("shows a pending question dock", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
@@ -92,7 +93,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||||
@@ -111,11 +112,11 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
|
|
||||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
const transport = await installSseTransport(page, {
|
const transport = await installSseTransport(page, {
|
||||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
server,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
})
|
})
|
||||||
await mockServer(page, { forms: [] })
|
await mockServer(page, { forms: [] })
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
@@ -138,26 +139,26 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
)
|
)
|
||||||
.toBe(cursor)
|
.toBe(cursor)
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
id: "evt_form_created",
|
||||||
payload: {
|
created: 1700000001000,
|
||||||
type: "form.created",
|
type: "form.created",
|
||||||
properties: {
|
location: { directory },
|
||||||
form: {
|
data: {
|
||||||
id: "frm_question_caret",
|
form: {
|
||||||
sessionID,
|
id: "frm_question_caret",
|
||||||
title: "Questions",
|
sessionID,
|
||||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
title: "Questions",
|
||||||
fields: [
|
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||||
{
|
fields: [
|
||||||
key: "q0",
|
{
|
||||||
type: "string",
|
key: "q0",
|
||||||
title: "Continue",
|
type: "string",
|
||||||
description: "Continue?",
|
title: "Continue",
|
||||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
description: "Continue?",
|
||||||
custom: true,
|
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||||
},
|
custom: true,
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -166,11 +167,11 @@ test("restores the draft caret before typing after a request dock closes", async
|
|||||||
await expect(editor).toHaveCount(0)
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
await transport.send({
|
await transport.send({
|
||||||
directory,
|
id: "evt_form_cancelled",
|
||||||
payload: {
|
created: 1700000002000,
|
||||||
type: "form.cancelled",
|
type: "form.cancelled",
|
||||||
properties: { sessionID, id: "frm_question_caret" },
|
location: { directory },
|
||||||
},
|
data: { sessionID, id: "frm_question_caret" },
|
||||||
})
|
})
|
||||||
await expect(question).toHaveCount(0)
|
await expect(question).toHaveCount(0)
|
||||||
await expect(editor).toBeVisible()
|
await expect(editor).toBeVisible()
|
||||||
@@ -183,13 +184,11 @@ async function mockServer(
|
|||||||
page: Page,
|
page: Page,
|
||||||
requests: {
|
requests: {
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
|
||||||
forms?: unknown[] | (() => unknown[])
|
forms?: unknown[] | (() => unknown[])
|
||||||
sessionStatus?: Record<string, unknown>
|
sessionStatus?: Record<string, unknown>
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -229,11 +228,7 @@ async function mockServer(
|
|||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
permissions: requests.permissions,
|
permissions: requests.permissions,
|
||||||
questions: requests.questions,
|
|
||||||
forms: requests.forms,
|
forms: requests.forms,
|
||||||
sessionStatus: requests.sessionStatus,
|
sessionStatus: requests.sessionStatus,
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||||
|
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
@@ -8,14 +9,11 @@ const sessionID = "ses_timeline_state_regression"
|
|||||||
const userMessageID = "msg_user_regression"
|
const userMessageID = "msg_user_regression"
|
||||||
const assistantMessageID = "msg_assistant_regression"
|
const assistantMessageID = "msg_assistant_regression"
|
||||||
const editPartID = "prt_0001_edit"
|
const editPartID = "prt_0001_edit"
|
||||||
const textPartID = "prt_9999_text"
|
const textPartID = `${assistantMessageID}:text:0`
|
||||||
const title = "Timeline collapse state regression"
|
const title = "Timeline collapse state regression"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
type EventPayload = {
|
type EventPayload = OpenCodeEvent
|
||||||
directory: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -27,25 +25,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userMessage = {
|
const userMessage = {
|
||||||
info: {
|
id: userMessageID,
|
||||||
id: userMessageID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 },
|
||||||
role: "user",
|
text: "Please edit the file.",
|
||||||
time: { created: 1700000000000 },
|
} satisfies SessionMessageInfo
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: "prt_user_text",
|
|
||||||
sessionID,
|
|
||||||
messageID: userMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Please edit the file.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const editPart = {
|
const editPart = {
|
||||||
id: editPartID,
|
id: editPartID,
|
||||||
@@ -74,31 +58,19 @@ const editPart = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const streamedTextPart = {
|
const streamedTextPart = {
|
||||||
id: textPartID,
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantMessageID,
|
|
||||||
type: "text",
|
|
||||||
text: "Streaming added a later assistant text part.",
|
text: "Streaming added a later assistant text part.",
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessage = {
|
const assistantMessage = {
|
||||||
info: {
|
id: assistantMessageID,
|
||||||
id: assistantMessageID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1700000001000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1700000001000 },
|
agent: "build",
|
||||||
parentID: userMessageID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
content: [toolContent(editPart)],
|
||||||
mode: "build",
|
} satisfies SessionMessageInfo
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
},
|
|
||||||
parts: [editPart],
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("regression: session timeline local row state", () => {
|
test.describe("regression: session timeline local row state", () => {
|
||||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||||
@@ -106,7 +78,7 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await mockServer(page, events)
|
await mockServer(page, events)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(sessionHref())
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
@@ -119,15 +91,11 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||||
await expectExpanded(wrapper, false)
|
await expectExpanded(wrapper, false)
|
||||||
|
|
||||||
events.push({
|
events.push(...textEvents())
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "message.part.updated",
|
|
||||||
properties: { part: streamedTextPart },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
|
||||||
expect(await readToolState(page)).toEqual({
|
expect(await readToolState(page)).toEqual({
|
||||||
expanded: false,
|
expanded: false,
|
||||||
@@ -136,13 +104,13 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
test("does not remount an edit diff when a sibling part arrives", async ({ page }) => {
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
await installDiffProbe(page)
|
await installDiffProbe(page)
|
||||||
await mockServer(page, events)
|
await mockServer(page, events)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(sessionHref())
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
@@ -151,15 +119,11 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
await expectAppVisible(file)
|
await expectAppVisible(file)
|
||||||
await markDiffProbe(page)
|
await markDiffProbe(page)
|
||||||
|
|
||||||
events.push({
|
events.push(...textEvents())
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "message.part.updated",
|
|
||||||
properties: { part: streamedTextPart },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
await expect(page.locator(`[data-timeline-part-id="${assistantMessageID}:text:0"]`).first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
const siblingProbe = await readDiffProbe(page)
|
const siblingProbe = await readDiffProbe(page)
|
||||||
expect(siblingProbe).toEqual({
|
expect(siblingProbe).toEqual({
|
||||||
fileMarker: "before",
|
fileMarker: "before",
|
||||||
@@ -169,27 +133,6 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
shadowRoots: 0,
|
shadowRoots: 0,
|
||||||
toolMarker: "before",
|
toolMarker: "before",
|
||||||
})
|
})
|
||||||
|
|
||||||
await markDiffProbe(page)
|
|
||||||
events.push({
|
|
||||||
directory,
|
|
||||||
payload: {
|
|
||||||
type: "message.part.updated",
|
|
||||||
properties: { part: editPartWithAdditions(2) },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
|
||||||
{ timeout: 10_000 },
|
|
||||||
)
|
|
||||||
expect(await readDiffProbe(page)).toEqual({
|
|
||||||
fileMarker: "before",
|
|
||||||
frameMarker: "before",
|
|
||||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
|
||||||
rowMarker: "before",
|
|
||||||
shadowRoots: 0,
|
|
||||||
toolMarker: "before",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
||||||
@@ -216,10 +159,10 @@ test.describe("regression: session timeline local row state", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(sessionHref())
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||||
@@ -293,7 +236,7 @@ async function readToolState(page: Page) {
|
|||||||
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
|
||||||
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
|
||||||
}),
|
}),
|
||||||
textPartID,
|
`${assistantMessageID}:text:0`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,22 +298,103 @@ async function readDiffProbe(page: Page) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function editPartWithAdditions(additions: number) {
|
function toolContent(part: typeof editPart): SessionMessageAssistant["content"][number] {
|
||||||
return {
|
return {
|
||||||
...editPart,
|
type: "tool",
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||||
state: {
|
state: {
|
||||||
...editPart.state,
|
status: "completed",
|
||||||
metadata: {
|
input: part.state.input,
|
||||||
...editPart.state.metadata,
|
content: [{ type: "text", text: part.state.output }],
|
||||||
filediff: {
|
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||||
...editPart.state.metadata.filediff,
|
|
||||||
additions,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let eventSequence = -1
|
||||||
|
|
||||||
|
function textEvents(): OpenCodeEvent[] {
|
||||||
|
return [
|
||||||
|
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
|
||||||
|
eventValue(
|
||||||
|
"session.text.ended",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
ordinal: 0,
|
||||||
|
text: streamedTextPart.text,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||||
|
return [
|
||||||
|
eventValue(
|
||||||
|
"session.tool.input.started",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
eventValue(
|
||||||
|
"session.tool.input.ended",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
id: part.callID,
|
||||||
|
text: JSON.stringify(part.state.input),
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
eventValue(
|
||||||
|
"session.tool.called",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
id: part.callID,
|
||||||
|
input: part.state.input,
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
eventValue(
|
||||||
|
"session.tool.success",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID,
|
||||||
|
id: part.callID,
|
||||||
|
content: [{ type: "text", text: part.state.output }],
|
||||||
|
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||||
|
type: Type,
|
||||||
|
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||||
|
version: 1 | 2,
|
||||||
|
): Extract<OpenCodeEvent, { type: Type }> {
|
||||||
|
eventSequence++
|
||||||
|
return {
|
||||||
|
id: `evt_collapse_${eventSequence}`,
|
||||||
|
created: 1700000002000 + eventSequence,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
location: { directory },
|
||||||
|
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||||
|
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||||
|
}
|
||||||
|
|
||||||
function readExpanded(element: Element) {
|
function readExpanded(element: Element) {
|
||||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||||
const aria = trigger?.getAttribute("aria-expanded")
|
const aria = trigger?.getAttribute("aria-expanded")
|
||||||
@@ -385,7 +409,12 @@ function readExpanded(element: Element) {
|
|||||||
return !!content && content.getBoundingClientRect().height > 0
|
return !!content && content.getBoundingClientRect().height > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
async function mockServer(
|
||||||
|
page: Page,
|
||||||
|
events: EventPayload[],
|
||||||
|
messages: SessionMessageInfo[] = [userMessage, assistantMessage],
|
||||||
|
) {
|
||||||
|
eventSequence = -1
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
@@ -437,3 +466,8 @@ function provider() {
|
|||||||
function base64Encode(value: string) {
|
function base64Encode(value: string) {
|
||||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionHref() {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +18,6 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma
|
|||||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||||
|
|
||||||
type Message = {
|
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
|
||||||
parts: Record<string, unknown>[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
||||||
|
|
||||||
test.describe("regression: session timeline context group resize", () => {
|
test.describe("regression: session timeline context group resize", () => {
|
||||||
@@ -30,7 +26,7 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(sessionHref())
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
||||||
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
||||||
@@ -45,7 +41,7 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
const events: OpenCodeEvent[] = []
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockServer(page, events, [
|
await mockServer(page, events, [
|
||||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||||
@@ -53,7 +49,7 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
])
|
])
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(sessionHref())
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const devtools = await page.context().newCDPSession(page)
|
const devtools = await page.context().newCDPSession(page)
|
||||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||||
@@ -75,25 +71,21 @@ test.describe("regression: session timeline context group resize", () => {
|
|||||||
})
|
})
|
||||||
await startVisualProbe(page, regions)
|
await startVisualProbe(page, regions)
|
||||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||||
events.push({
|
events.push(
|
||||||
directory,
|
...toolEvents(
|
||||||
payload: {
|
contextTool(
|
||||||
type: "message.part.updated",
|
contextIDs[index]!,
|
||||||
properties: {
|
id("msg_assistant", 10),
|
||||||
part: contextTool(
|
["read", "glob", "grep", "list"][index]!,
|
||||||
contextIDs[index]!,
|
[
|
||||||
id("msg_assistant", 10),
|
{ filePath: "src/recent-a.ts" },
|
||||||
["read", "glob", "grep", "list"][index]!,
|
{ path: directory, pattern: "**/*.ts" },
|
||||||
[
|
{ path: directory, pattern: "Explored" },
|
||||||
{ filePath: "src/recent-a.ts" },
|
{ path: "src" },
|
||||||
{ path: directory, pattern: "**/*.ts" },
|
][index]!,
|
||||||
{ path: directory, pattern: "Explored" },
|
),
|
||||||
{ path: "src" },
|
),
|
||||||
][index]!,
|
)
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await page.waitForTimeout(delay)
|
await page.waitForTimeout(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,74 +203,51 @@ async function sampleExpansion(page: Page) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): SessionMessageInfo[] {
|
||||||
const userID = id("msg_user", index)
|
const userID = id("msg_user", index)
|
||||||
const assistantID = id("msg_assistant", index)
|
const assistantID = id("msg_assistant", index)
|
||||||
|
const content: SessionMessageAssistant["content"] = target
|
||||||
|
? [
|
||||||
|
toolContent(
|
||||||
|
contextTool(
|
||||||
|
contextIDs[0]!,
|
||||||
|
assistantID,
|
||||||
|
"read",
|
||||||
|
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||||
|
toolContent(
|
||||||
|
contextTool(
|
||||||
|
contextIDs[2]!,
|
||||||
|
assistantID,
|
||||||
|
"grep",
|
||||||
|
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||||
|
status,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
toolContent(contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status)),
|
||||||
|
{ type: "text", text: "This assistant text is immediately after the explored context group." },
|
||||||
|
]
|
||||||
|
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
info: {
|
id: userID,
|
||||||
id: userID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
role: "user",
|
text: `User message ${index}`,
|
||||||
time: { created: 1700000000000 + index * 10_000 },
|
|
||||||
summary: { diffs: [] },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
info: {
|
id: assistantID,
|
||||||
id: assistantID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
agent: "build",
|
||||||
parentID: userID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
finish: "stop",
|
||||||
mode: "build",
|
content,
|
||||||
agent: "build",
|
|
||||||
path: { cwd: directory, root: directory },
|
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
||||||
variant: "max",
|
|
||||||
finish: "stop",
|
|
||||||
},
|
|
||||||
parts: target
|
|
||||||
? [
|
|
||||||
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,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "text",
|
|
||||||
text: "This assistant text is immediately after the explored context group.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
id: id("prt_text", index),
|
|
||||||
sessionID,
|
|
||||||
messageID: assistantID,
|
|
||||||
type: "text",
|
|
||||||
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -308,11 +277,76 @@ function contextTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(
|
type ContextTool = ReturnType<typeof contextTool>
|
||||||
page: Page,
|
|
||||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
function toolContent(part: ContextTool): SessionMessageAssistant["content"][number] {
|
||||||
fixtureMessages = messages,
|
const base = {
|
||||||
) {
|
type: "tool" as const,
|
||||||
|
id: part.callID,
|
||||||
|
name: part.tool,
|
||||||
|
time: {
|
||||||
|
created: part.state.time.start,
|
||||||
|
ran: part.state.time.start,
|
||||||
|
...(part.state.status === "completed" ? { completed: part.state.time.end } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (part.state.status === "running")
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: {
|
||||||
|
status: "running",
|
||||||
|
input: part.state.input as Record<string, JsonValue>,
|
||||||
|
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: part.state.input as Record<string, JsonValue>,
|
||||||
|
content: [{ type: "text", text: part.state.output }],
|
||||||
|
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let eventSequence = -1
|
||||||
|
|
||||||
|
function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||||
|
return [
|
||||||
|
eventValue(
|
||||||
|
"session.tool.success",
|
||||||
|
{
|
||||||
|
sessionID,
|
||||||
|
assistantMessageID: part.messageID,
|
||||||
|
id: part.callID,
|
||||||
|
content: [{ type: "text", text: part.state.output }],
|
||||||
|
metadata: part.state.metadata,
|
||||||
|
executed: true,
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||||
|
type: Type,
|
||||||
|
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||||
|
version: 1 | 2,
|
||||||
|
): Extract<OpenCodeEvent, { type: Type }> {
|
||||||
|
eventSequence++
|
||||||
|
return {
|
||||||
|
id: `evt_context_resize_${eventSequence}`,
|
||||||
|
created: 1700000002000 + eventSequence,
|
||||||
|
type,
|
||||||
|
data,
|
||||||
|
location: { directory },
|
||||||
|
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||||
|
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
|
||||||
|
eventSequence = -1
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
@@ -372,3 +406,8 @@ function provider() {
|
|||||||
function base64Encode(value: string) {
|
function base64Encode(value: string) {
|
||||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionHref() {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,5 @@
|
|||||||
import { expect, test } from "@playwright/test"
|
import { expect, test } from "@playwright/test"
|
||||||
import {
|
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||||
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 }) => {
|
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||||
const patchID = "prt_nested_patch"
|
const patchID = "prt_nested_patch"
|
||||||
@@ -68,25 +43,6 @@ function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function source(changed: boolean) {
|
||||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import {
|
import {
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
@@ -17,6 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
|||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const messagePageSize = 200
|
const messagePageSize = 200
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||||
return [
|
return [
|
||||||
@@ -29,19 +31,19 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
|||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}).flat()
|
}).flat()
|
||||||
const assistants = messages.filter((message) => message.info.role === "assistant")
|
const assistants = messages.filter((message): message is SessionMessageAssistant => message.type === "assistant")
|
||||||
const lastAssistant = assistants.at(-1)!
|
const lastAssistant = assistants.at(-1)!
|
||||||
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
|
const lastPartID = `${assistants.at(-1)!.id}:text:0`
|
||||||
const userPartID = `${messages.at(-2)!.info.id}:text:0`
|
const userPartID = `${messages.at(-2)!.id}:text:0`
|
||||||
const completed = {
|
const completed = {
|
||||||
...lastAssistant.info,
|
...lastAssistant,
|
||||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 },
|
||||||
}
|
}
|
||||||
const scenarios = [
|
const scenarios = [
|
||||||
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||||
{
|
{
|
||||||
name: "interruption",
|
name: "interruption",
|
||||||
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
info: { ...completed, error: { type: "MessageAbortedError", message: "Stopped" } },
|
||||||
idleFirst: true,
|
idleFirst: true,
|
||||||
interrupted: true,
|
interrupted: true,
|
||||||
},
|
},
|
||||||
@@ -56,12 +58,11 @@ for (const scenario of scenarios) {
|
|||||||
const roots: { sessionID: string; messageID: string }[] = []
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
const sequence: string[] = []
|
const sequence: string[] = []
|
||||||
const history = Promise.withResolvers<void>()
|
const history = Promise.withResolvers<void>()
|
||||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
const transport = await installSseTransport(page, {
|
||||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
server,
|
||||||
retry: 20,
|
retry: 20,
|
||||||
})
|
})
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: {
|
provider: {
|
||||||
@@ -94,15 +95,15 @@ for (const scenario of scenarios) {
|
|||||||
},
|
},
|
||||||
message: (requestedSessionID, messageID) => {
|
message: (requestedSessionID, messageID) => {
|
||||||
if (requestedSessionID !== sessionID) return
|
if (requestedSessionID !== sessionID) return
|
||||||
return messages.find((item) => item.info.id === messageID)
|
return messages.find((item) => item.id === messageID)
|
||||||
},
|
},
|
||||||
pageMessages: (_, limit, before) => {
|
pageMessages: (_, limit, before) => {
|
||||||
pages.push({ before, limit })
|
pages.push({ before, limit })
|
||||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return {
|
return {
|
||||||
items: messages.slice(start, end),
|
items: messages.slice(start, end),
|
||||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
cursor: start > 0 ? messages[start]!.id : undefined,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -152,7 +153,7 @@ for (const scenario of scenarios) {
|
|||||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await transport.waitForConnection()
|
await transport.waitForConnection()
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||||
@@ -169,7 +170,7 @@ for (const scenario of scenarios) {
|
|||||||
expect(sequence.slice(0, 3)).toEqual([
|
expect(sequence.slice(0, 3)).toEqual([
|
||||||
"messages:start:latest",
|
"messages:start:latest",
|
||||||
"messages:end:latest",
|
"messages:end:latest",
|
||||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
`messages:start:${messages.at(-messagePageSize)!.id}`,
|
||||||
])
|
])
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
;(
|
;(
|
||||||
@@ -187,7 +188,7 @@ for (const scenario of scenarios) {
|
|||||||
await waitForProbeSamples(page, beforeHistory)
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
expect(pages).toEqual([
|
expect(pages).toEqual([
|
||||||
{ before: undefined, limit: messagePageSize },
|
{ before: undefined, limit: messagePageSize },
|
||||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||||
])
|
])
|
||||||
expect(roots).toEqual([])
|
expect(roots).toEqual([])
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
messageUpdated,
|
messageUpdated,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
reasoningPart,
|
reasoningPart,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
status,
|
status,
|
||||||
@@ -65,7 +66,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
|||||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||||
await timeline.send(status("idle"), 300)
|
await timeline.send(status("idle"), 300)
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||||
@@ -95,16 +96,16 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
|||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||||
await timeline.send(status("retry"), 180)
|
await timeline.send(status("retry"), 180)
|
||||||
await expect(page.locator('[data-timeline-row="Retry"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
|
||||||
await timeline.send(status("busy", 2), 180)
|
await timeline.send(status("busy", 2), 180)
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||||
await timeline.send(status("idle"), 350)
|
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="Thinking"]')).toHaveCount(0)
|
||||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
|
||||||
|
"Recovered response",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
function lines(count: number) {
|
function lines(count: number) {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
|||||||
ownerWarnings.push(message.text())
|
ownerWarnings.push(message.text())
|
||||||
})
|
})
|
||||||
await setupTimeline(page, {
|
await setupTimeline(page, {
|
||||||
currentMessages: [
|
sessionMessages: [
|
||||||
user,
|
user,
|
||||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||||
assistant(true),
|
assistant(true),
|
||||||
@@ -74,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||||
const card = page.locator('[data-component="task-tool-card"]')
|
const card = page.locator('[data-component="task-tool-card"]')
|
||||||
await expect(card).toBeVisible()
|
await expect(card).toBeVisible()
|
||||||
await expect(card).toContainText("Inspect code")
|
await expect(card).toContainText("Inspect code")
|
||||||
@@ -92,14 +92,14 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("waits for completion before labeling requested background work", async ({ page }) => {
|
test("waits for completion before labeling requested background work", async ({ page }) => {
|
||||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true, undefined, true)] })
|
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
|
||||||
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||||
const childID = "ses_running_child"
|
const childID = "ses_running_child"
|
||||||
await setupTimeline(page, {
|
await setupTimeline(page, {
|
||||||
currentMessages: [user, assistant(false, true, childID)],
|
sessionMessages: [user, assistant(false, true, childID)],
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||||
})
|
})
|
||||||
@@ -113,7 +113,7 @@ test("navigates from a running subagent card and hides background controls in th
|
|||||||
test("shows a badge for active background work", async ({ page }) => {
|
test("shows a badge for active background work", async ({ page }) => {
|
||||||
const childID = "ses_background_child"
|
const childID = "ses_background_child"
|
||||||
await setupTimeline(page, {
|
await setupTimeline(page, {
|
||||||
currentMessages: [user, assistant(true)],
|
sessionMessages: [user, assistant(true)],
|
||||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||||
sessionStatus: { [childID]: { type: "busy" } },
|
sessionStatus: { [childID]: { type: "busy" } },
|
||||||
})
|
})
|
||||||
@@ -125,7 +125,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
|||||||
const backgroundID = "ses_background_existing"
|
const backgroundID = "ses_background_existing"
|
||||||
const blockingID = "ses_background_blocking"
|
const blockingID = "ses_background_blocking"
|
||||||
const timeline = await setupTimeline(page, {
|
const timeline = await setupTimeline(page, {
|
||||||
currentMessages: [
|
sessionMessages: [
|
||||||
user,
|
user,
|
||||||
{
|
{
|
||||||
id: "msg_backgrounded",
|
id: "msg_backgrounded",
|
||||||
@@ -203,7 +203,12 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
|||||||
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'),
|
||||||
).toHaveAttribute("data-active", "true")
|
).toHaveAttribute("data-active", "true")
|
||||||
|
|
||||||
await timeline.send(event("session.status", { sessionID: backgroundID, status: { type: "idle" } }))
|
await timeline.transport.send({
|
||||||
|
id: "evt_background_succeeded",
|
||||||
|
created: Date.now(),
|
||||||
|
type: "session.execution.succeeded",
|
||||||
|
data: { sessionID: backgroundID },
|
||||||
|
} as never)
|
||||||
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
await expect(backgroundCard.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0)
|
||||||
await expect(backgroundCard).toContainText("Background task (background)")
|
await expect(backgroundCard).toContainText("Background task (background)")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,33 +71,20 @@ test.describe("session timeline projection", () => {
|
|||||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||||
const firstUser = userMessage(
|
const firstUser = userMessage(
|
||||||
[
|
[
|
||||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
userText("Keep this stable", { id: "prt_comment" }),
|
||||||
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" }),
|
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||||
],
|
],
|
||||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||||
)
|
)
|
||||||
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
const aborted = assistantMessage([{ id: "prt_before_abort", type: "text", text: "Before interruption" }], {
|
||||||
id: "msg_1001_assistant_aborted",
|
id: "msg_1001_assistant_aborted",
|
||||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||||
})
|
})
|
||||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||||
id: "msg_1002_assistant_failed",
|
id: "msg_1002_assistant_failed",
|
||||||
error: {
|
error: {
|
||||||
name: "APIError",
|
type: "APIError",
|
||||||
data: {
|
message: "Visible provider failure",
|
||||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
|
||||||
isRetryable: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
created: 1700000003000,
|
created: 1700000003000,
|
||||||
})
|
})
|
||||||
@@ -122,52 +109,11 @@ test.describe("session timeline projection", () => {
|
|||||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("renders legacy synthetic comments as ordinary V2 user text", 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],
|
|
||||||
settings: { newLayoutDesigns: false },
|
|
||||||
})
|
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
|
||||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText(
|
|
||||||
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
|
|
||||||
{ exact: true },
|
|
||||||
),
|
|
||||||
).toBeVisible()
|
|
||||||
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
|
|
||||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||||
const user = userMessage()
|
const user = userMessage()
|
||||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||||
id: "msg_1001_before",
|
id: "msg_1001_before",
|
||||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||||
})
|
})
|
||||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||||
id: "msg_1002_after",
|
id: "msg_1002_after",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
completedAssistantInfo,
|
completedAssistantInfo,
|
||||||
messageUpdated,
|
messageUpdated,
|
||||||
partUpdated,
|
partUpdated,
|
||||||
|
renderedPartID,
|
||||||
setupTimeline,
|
setupTimeline,
|
||||||
shell,
|
shell,
|
||||||
status,
|
status,
|
||||||
@@ -36,8 +37,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
|||||||
await timeline.send(status("busy"), 100)
|
await timeline.send(status("busy"), 100)
|
||||||
await timeline.send(status("idle"), 100)
|
await timeline.send(status("idle"), 100)
|
||||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||||
|
|
||||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||||
|
"Final after early idle",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ for (const deviceScaleFactor of [1.25, 1.5]) {
|
|||||||
const shellID = "prt_shell_outline"
|
const shellID = "prt_shell_outline"
|
||||||
const timeline = await setupTimeline(page, {
|
const timeline = await setupTimeline(page, {
|
||||||
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||||
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
settings: { shellToolPartsExpanded: true },
|
||||||
reducedMotion: true,
|
reducedMotion: true,
|
||||||
deviceScaleFactor,
|
deviceScaleFactor,
|
||||||
})
|
})
|
||||||
@@ -82,7 +82,7 @@ test("keeps the patch card inside a fractionally short virtual row", async ({ pa
|
|||||||
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
settings: { editToolPartsExpanded: true },
|
||||||
reducedMotion: true,
|
reducedMotion: true,
|
||||||
})
|
})
|
||||||
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||||
|
|||||||
@@ -83,6 +83,26 @@ test("labels all web search provider variants", async ({ page }) => {
|
|||||||
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("labels completed searches with result counts", async ({ page }) => {
|
||||||
|
const glob = "prt_glob_count"
|
||||||
|
const grep = "prt_grep_count"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(),
|
||||||
|
assistantMessage([
|
||||||
|
toolPart(glob, "glob", "completed", { path: ".", pattern: "**/*.ts" }, { metadata: { count: 1 } }),
|
||||||
|
toolPart(grep, "grep", "completed", { path: ".", pattern: "value" }, { metadata: { matches: 12 } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
|
||||||
|
await group.locator('[data-slot="collapsible-trigger"]').click()
|
||||||
|
const rows = group.locator('[data-component="tool-trigger"]')
|
||||||
|
await expect(rows.nth(0)).toContainText("(1 match)")
|
||||||
|
await expect(rows.nth(1)).toContainText("(12 matches)")
|
||||||
|
})
|
||||||
|
|
||||||
test("labels V2 read tools from their path input", async ({ page }) => {
|
test("labels V2 read tools from their path input", async ({ page }) => {
|
||||||
const id = "prt_read_path"
|
const id = "prt_read_path"
|
||||||
await setupTimeline(page, {
|
await setupTimeline(page, {
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
@@ -1,43 +1,44 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page)
|
const timeline = await setupTimeline(page)
|
||||||
|
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)!
|
||||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)!
|
||||||
|
|
||||||
await timeline.waitForPart("prt_transport_first")
|
await timeline.waitForPart("prt_transport_first")
|
||||||
await timeline.waitForPart("prt_transport_second")
|
await timeline.waitForPart("prt_transport_second")
|
||||||
expect(first.connectionID).toBe(second.connectionID)
|
expect(first.connectionID).toBe(second.connectionID)
|
||||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
expect(await timeline.transport.acknowledgements()).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page)
|
const timeline = await setupTimeline(page)
|
||||||
const acknowledgements = await timeline.transport.burst([
|
const acknowledgements = await timeline.transport.burst([
|
||||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
...partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
...partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||||
])
|
])
|
||||||
|
|
||||||
await timeline.waitForPart("prt_transport_burst_a")
|
await timeline.waitForPart("prt_transport_burst_a")
|
||||||
await timeline.waitForPart("prt_transport_burst_b")
|
await timeline.waitForPart("prt_transport_burst_b")
|
||||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1])
|
||||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page)
|
const timeline = await setupTimeline(page)
|
||||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||||
|
await timeline.transport.send(started!)
|
||||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||||
const multibyte = encoded.indexOf(snowman)
|
const multibyte = encoded.indexOf(snowman)
|
||||||
|
|
||||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2])
|
||||||
|
|
||||||
await timeline.waitForPart("prt_transport_split")
|
await timeline.waitForPart("prt_transport_split")
|
||||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText(
|
||||||
"split snowman \u2603\u2603\u2603",
|
"split snowman \u2603\u2603\u2603",
|
||||||
)
|
)
|
||||||
expect(acknowledgement.chunkCount).toBe(4)
|
expect(acknowledgement.chunkCount).toBe(4)
|
||||||
@@ -46,12 +47,11 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
|||||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page)
|
const timeline = await setupTimeline(page)
|
||||||
const partID = "prt_transport_heartbeat_sentinel"
|
const partID = "prt_transport_heartbeat_sentinel"
|
||||||
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
|
const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)!
|
||||||
await timeline.waitForPart(partID)
|
await timeline.waitForPart(partID)
|
||||||
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
|
await expect(
|
||||||
"data-markdown-ready",
|
page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`),
|
||||||
"",
|
).toHaveAttribute("data-markdown-ready", "")
|
||||||
)
|
|
||||||
const before = await timelineRows(page)
|
const before = await timelineRows(page)
|
||||||
const heartbeat = await timeline.transport.heartbeat()
|
const heartbeat = await timeline.transport.heartbeat()
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ test("reconnects after a clean close", async ({ page }) => {
|
|||||||
|
|
||||||
await timeline.transport.close()
|
await timeline.transport.close()
|
||||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
await timeline.transport.burst(partUpdated(textPart("prt_transport_close", "after close")))
|
||||||
|
|
||||||
await timeline.waitForPart("prt_transport_close")
|
await timeline.waitForPart("prt_transport_close")
|
||||||
expect(second.id).toBeGreaterThan(first.id)
|
expect(second.id).toBeGreaterThan(first.id)
|
||||||
@@ -79,7 +79,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
|
|
||||||
await timeline.transport.error("contract failure")
|
await timeline.transport.error("contract failure")
|
||||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||||
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
await timeline.transport.burst(partUpdated(textPart("prt_transport_error", "after error")))
|
||||||
|
|
||||||
await timeline.waitForPart("prt_transport_error")
|
await timeline.waitForPart("prt_transport_error")
|
||||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||||
@@ -89,9 +89,13 @@ test("reconnects after a stream error", async ({ page }) => {
|
|||||||
|
|
||||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const events = partUpdated(textPart("prt_transport_id", "event with id"))
|
||||||
id: "timeline-event-7",
|
const first = (
|
||||||
})
|
await timeline.transport.burst(
|
||||||
|
events,
|
||||||
|
events.map((_, index) => (index === events.length - 1 ? { id: "timeline-event-7" } : {})),
|
||||||
|
)
|
||||||
|
).at(-1)!
|
||||||
await timeline.waitForPart("prt_transport_id")
|
await timeline.waitForPart("prt_transport_id")
|
||||||
|
|
||||||
await timeline.transport.error("retry with event id")
|
await timeline.transport.error("retry with event id")
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
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/TodoDockNavigation"
|
|
||||||
const projectID = "proj_todo_dock_navigation"
|
|
||||||
const sourceID = "ses_todo_dock_source"
|
|
||||||
const otherID = "ses_todo_dock_other"
|
|
||||||
const sourceTitle = "Todo dock animation"
|
|
||||||
const otherTitle = "Separate session"
|
|
||||||
|
|
||||||
const activeTodos = [
|
|
||||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
|
||||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
|
||||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
|
||||||
]
|
|
||||||
|
|
||||||
type EventPayload = {
|
|
||||||
directory: string
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
|
||||||
|
|
||||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000)
|
|
||||||
const events: EventPayload[] = []
|
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
|
||||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
|
||||||
|
|
||||||
await mockOpenCodeServer(page, {
|
|
||||||
directory,
|
|
||||||
project: {
|
|
||||||
id: projectID,
|
|
||||||
worktree: directory,
|
|
||||||
vcs: "git",
|
|
||||||
name: "todo-dock-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(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
|
||||||
pageMessages: () => ({ items: [] }),
|
|
||||||
events: () => events.splice(0, 1),
|
|
||||||
eventRetry: 16,
|
|
||||||
sessionStatus: () => sessionStatus,
|
|
||||||
todos: (sessionID) => todos[sessionID] ?? [],
|
|
||||||
})
|
|
||||||
await configurePage(page)
|
|
||||||
|
|
||||||
await page.goto(sessionHref(sourceID))
|
|
||||||
await expectSessionTitle(page, sourceTitle)
|
|
||||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
|
|
||||||
sessionStatus[sourceID] = { type: "busy" }
|
|
||||||
events.push(statusEvent(sourceID, "busy"))
|
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
|
||||||
|
|
||||||
await page.waitForTimeout(700)
|
|
||||||
const opening = sampleDock(page, 1_000)
|
|
||||||
todos[sourceID] = activeTodos
|
|
||||||
events.push(todoEvent(sourceID, activeTodos))
|
|
||||||
await expect(dock).toBeVisible()
|
|
||||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
|
||||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
|
||||||
|
|
||||||
await switchSession(page, otherID, otherTitle)
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
|
|
||||||
await switchSession(page, sourceID, sourceTitle)
|
|
||||||
await expect(dock).toHaveCount(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
function session(id: string, title: string, created: number) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slug: id,
|
|
||||||
projectID,
|
|
||||||
directory,
|
|
||||||
title,
|
|
||||||
version: "dev",
|
|
||||||
time: { created, updated: created },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
|
||||||
return {
|
|
||||||
directory,
|
|
||||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
|
||||||
return {
|
|
||||||
directory,
|
|
||||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, dirBase64, server, sessionIDs }) => {
|
|
||||||
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(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
|
||||||
const href = sessionHref(sessionID)
|
|
||||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
|
||||||
await expect(tab).toBeVisible()
|
|
||||||
await tab.click()
|
|
||||||
await expectSessionTitle(page, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
function sampleDock(page: Page, duration: number) {
|
|
||||||
return page.evaluate(async (duration) => {
|
|
||||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
|
||||||
const start = performance.now()
|
|
||||||
while (performance.now() - start < duration) {
|
|
||||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
|
||||||
const clip = dock?.parentElement?.parentElement
|
|
||||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
|
||||||
samples.push({
|
|
||||||
present: !!dock,
|
|
||||||
height: clip?.getBoundingClientRect().height ?? 0,
|
|
||||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
|
||||||
})
|
|
||||||
await new Promise(requestAnimationFrame)
|
|
||||||
}
|
|
||||||
return samples
|
|
||||||
}, duration)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
@@ -12,8 +13,6 @@ const childTitle = "Subagent child session"
|
|||||||
// Child session pages derive their heading from the task part that spawned them.
|
// Child session pages derive their heading from the task part that spawned them.
|
||||||
const taskDescription = "Inspect child navigation"
|
const taskDescription = "Inspect child navigation"
|
||||||
|
|
||||||
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
||||||
@@ -27,7 +26,7 @@ test("navigates to a subagent child session missing from the session list", asyn
|
|||||||
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps the parent visible while child lineage resolves", async ({ page }) => {
|
test("keeps the parent visible while the child session resolves", async ({ page }) => {
|
||||||
await setup(page)
|
await setup(page)
|
||||||
const requested = Promise.withResolvers<void>()
|
const requested = Promise.withResolvers<void>()
|
||||||
const release = Promise.withResolvers<void>()
|
const release = Promise.withResolvers<void>()
|
||||||
@@ -52,14 +51,18 @@ test("keeps the parent visible while child lineage resolves", async ({ page }) =
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||||
const events: EventPayload[] = []
|
const events: OpenCodeEvent[] = []
|
||||||
await setup(page, () => events.splice(0, 1))
|
await setup(page, () => events.splice(0, 1))
|
||||||
await openChildFromParent(page)
|
await openChildFromParent(page)
|
||||||
await expectSessionTitle(page, taskDescription)
|
await expectSessionTitle(page, taskDescription)
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
directory,
|
id: "evt_session_deleted",
|
||||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
created: 1700000003000,
|
||||||
|
type: "session.deleted",
|
||||||
|
durable: { aggregateID: childID, seq: 1, version: 2 },
|
||||||
|
location: { directory },
|
||||||
|
data: { sessionID: childID },
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||||
@@ -67,7 +70,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
|||||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function setup(page: Page, events?: () => EventPayload[]) {
|
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
@@ -142,60 +145,36 @@ function childSession() {
|
|||||||
return session(childID, childTitle, 1700000001000, { parentID })
|
return session(childID, childTitle, 1700000001000, { parentID })
|
||||||
}
|
}
|
||||||
|
|
||||||
function parentMessages() {
|
function parentMessages(): SessionMessageInfo[] {
|
||||||
const userID = "msg_user_0001"
|
const userID = "msg_user_0001"
|
||||||
const assistantID = "msg_assistant_0001"
|
const assistantID = "msg_assistant_0001"
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
info: {
|
id: userID,
|
||||||
id: userID,
|
type: "user",
|
||||||
sessionID: parentID,
|
time: { created: 1700000000000 },
|
||||||
role: "user",
|
text: "Delegate work to a subagent",
|
||||||
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,
|
||||||
id: assistantID,
|
type: "assistant",
|
||||||
sessionID: parentID,
|
time: { created: 1700000001000, completed: 1700000002000 },
|
||||||
role: "assistant",
|
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||||
time: { created: 1700000001000, completed: 1700000002000 },
|
agent: "build",
|
||||||
parentID: userID,
|
cost: 0.01,
|
||||||
modelID: "claude-opus-4-6",
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: "opencode",
|
finish: "stop",
|
||||||
mode: "build",
|
content: [
|
||||||
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",
|
type: "tool",
|
||||||
callID: "call_task_0001",
|
id: "call_task_0001",
|
||||||
tool: "task",
|
name: "task",
|
||||||
|
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||||
state: {
|
state: {
|
||||||
status: "completed",
|
status: "completed",
|
||||||
input: { description: taskDescription, subagent_type: "explore" },
|
input: { description: taskDescription, subagent_type: "explore" },
|
||||||
output: "Subagent finished",
|
content: [{ type: "text", text: "Subagent finished" }],
|
||||||
title: taskDescription,
|
|
||||||
metadata: { sessionId: childID },
|
metadata: { sessionId: childID },
|
||||||
time: { start: 1700000001000, end: 1700000002000 },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -207,7 +186,6 @@ async function configurePage(page: Page) {
|
|||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessionId }) => {
|
({ directory, server, sessionId }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
|||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ server, sessionA, sessionB }) => {
|
({ server, sessionA, sessionB }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
@@ -46,7 +45,6 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
|||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ server, sessionA, unresolved, sessionC }) => {
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.window.browser.dat:tabs",
|
"opencode.window.browser.dat:tabs",
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
@@ -87,8 +85,7 @@ async function mockServer(page: Page) {
|
|||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||||
return new Promise(() => {})
|
|
||||||
if (url.pathname === "/api/event") return sse(route)
|
if (url.pathname === "/api/event") return sse(route)
|
||||||
if (url.pathname === "/api/session")
|
if (url.pathname === "/api/session")
|
||||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||||
@@ -97,41 +94,29 @@ async function mockServer(page: Page) {
|
|||||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||||
return json(route, { data: [], cursor: {} })
|
return json(route, { data: [], cursor: {} })
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (url.pathname === "/api/model/default")
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||||
return json(route, [])
|
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||||
if (url.pathname === "/provider")
|
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/api/mcp/resource")
|
||||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||||
const project = {
|
const project = {
|
||||||
id: sessionA.projectID,
|
id: sessionA.projectID,
|
||||||
worktree: sessionA.directory,
|
canonical: sessionA.directory,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
time: { created: 1, updated: 1 },
|
time: { created: 1, updated: 1 },
|
||||||
sandboxes: [],
|
sandboxes: [],
|
||||||
}
|
}
|
||||||
return json(route, url.pathname === "/project" ? [project] : project)
|
return json(
|
||||||
|
route,
|
||||||
|
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (url.pathname === "/path")
|
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||||
return json(route, {
|
|
||||||
state: sessionA.directory,
|
|
||||||
config: sessionA.directory,
|
|
||||||
worktree: sessionA.directory,
|
|
||||||
directory: sessionA.directory,
|
|
||||||
home: sessionA.directory,
|
|
||||||
})
|
|
||||||
if (url.pathname === "/api/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" })
|
|
||||||
if (url.pathname === "/api/vcs")
|
if (url.pathname === "/api/vcs")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: { directory: sessionA.directory },
|
location: { directory: sessionA.directory },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
@@ -8,12 +8,12 @@ const projectID = "proj_terminal_composer_focus"
|
|||||||
const sessionID = "ses_terminal_composer_focus"
|
const sessionID = "ses_terminal_composer_focus"
|
||||||
const ptyID = "pty_terminal_composer_focus"
|
const ptyID = "pty_terminal_composer_focus"
|
||||||
const newPtyID = "pty_terminal_composer_focus_new"
|
const newPtyID = "pty_terminal_composer_focus_new"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test.use({ viewport: { width: 1440, height: 900 } })
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
@@ -71,13 +71,10 @@ test.beforeEach(async ({ page }) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||||
await page.addInitScript(() => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
@@ -116,7 +113,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
|||||||
})
|
})
|
||||||
await seedCachedTerminal(page)
|
await seedCachedTerminal(page)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
@@ -142,7 +139,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
|||||||
await route.continue()
|
await route.continue()
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
@@ -187,7 +184,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
|||||||
)
|
)
|
||||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, "Terminal composer focus")
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
const composer = page.locator('[data-component="prompt-input"]')
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
@@ -214,12 +211,18 @@ function seedCachedTerminal(page: Page) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
{ terminalKey: terminalStorageKey(), ptyID },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function terminalStorageKey() {
|
||||||
|
const dir = base64Encode(directory)
|
||||||
|
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
|
||||||
|
}
|
||||||
|
|
||||||
function ptyLocation() {
|
function ptyLocation() {
|
||||||
return { directory, project: { id: projectID, directory } }
|
return { directory, project: { id: projectID, directory, canonical: directory } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function ptyInfo(id: string, title: string) {
|
function ptyInfo(id: string, title: string) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/HiddenTerminalRegression"
|
|||||||
const projectID = "proj_hidden_terminal_regression"
|
const projectID = "proj_hidden_terminal_regression"
|
||||||
const sessionID = "ses_hidden_terminal_regression"
|
const sessionID = "ses_hidden_terminal_regression"
|
||||||
const title = "Hidden terminal regression"
|
const title = "Hidden terminal regression"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
@@ -91,7 +92,7 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
|||||||
)
|
)
|
||||||
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
|||||||
@@ -121,7 +121,6 @@ async function setup(page: Page) {
|
|||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ directory, server, sessions }) => {
|
({ directory, server, sessions }) => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:server",
|
"opencode.global.dat:server",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -21,16 +21,29 @@ const words = [
|
|||||||
"vector",
|
"vector",
|
||||||
]
|
]
|
||||||
|
|
||||||
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
const sourceID = "ses_smoke_source"
|
const sourceID = "ses_smoke_source"
|
||||||
const targetID = "ses_smoke_target"
|
const targetID = "ses_smoke_target"
|
||||||
const directory = "C:/OpenCode/SmokeProject"
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
const projectID = "proj_smoke_timeline"
|
const projectID = "proj_smoke_timeline"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
|
|
||||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
type MessagePart = {
|
||||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
id: string
|
||||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
type: "text" | "reasoning" | "tool"
|
||||||
|
text?: string
|
||||||
|
time?: { start: number; end?: number }
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
status: "completed"
|
||||||
|
input: Record<string, unknown>
|
||||||
|
output: string
|
||||||
|
title: unknown
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
time: { start: number; end: number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function lorem(seed: number, length: number) {
|
function lorem(seed: number, length: number) {
|
||||||
let out = ""
|
let out = ""
|
||||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
|||||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||||
const messageID = id("msg_user", index)
|
const messageID = id("msg_user", index)
|
||||||
return {
|
return {
|
||||||
info: {
|
id: messageID,
|
||||||
id: messageID,
|
type: "user",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 },
|
||||||
role: "user",
|
text: lorem(index, textLength),
|
||||||
time: { created: 1700000000000 + index * 10_000 },
|
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||||
summary: { diffs },
|
|
||||||
agent: "build",
|
|
||||||
model,
|
|
||||||
},
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
id: id("prt_user_text", index),
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
type: "text",
|
|
||||||
text: lorem(index, textLength),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
function assistantMessage(
|
||||||
|
_sessionID: string,
|
||||||
|
index: number,
|
||||||
|
_parentID: string,
|
||||||
|
parts: MessagePart[],
|
||||||
|
): SessionMessageInfo {
|
||||||
const messageID = id("msg_assistant", index)
|
const messageID = id("msg_assistant", index)
|
||||||
return {
|
return {
|
||||||
info: {
|
id: messageID,
|
||||||
id: messageID,
|
type: "assistant",
|
||||||
sessionID,
|
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||||
role: "assistant",
|
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
agent: "build",
|
||||||
parentID,
|
cost: 0.01,
|
||||||
modelID: model.modelID,
|
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
providerID: model.providerID,
|
finish: "stop",
|
||||||
mode: "build",
|
content: parts.map(messageContent),
|
||||||
agent: "build",
|
}
|
||||||
path: { cwd: directory, root: directory },
|
}
|
||||||
cost: 0.01,
|
|
||||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||||
variant: "max",
|
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||||
finish: "stop",
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning",
|
||||||
|
text: part.text ?? "",
|
||||||
|
time: part.time
|
||||||
|
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
const state = part.state!
|
||||||
|
return {
|
||||||
|
type: "tool",
|
||||||
|
id: part.callID ?? part.id,
|
||||||
|
name: part.tool!,
|
||||||
|
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: state.input as Record<string, JsonValue>,
|
||||||
|
content: [{ type: "text", text: state.output }],
|
||||||
|
metadata: state.metadata as Record<string, JsonValue>,
|
||||||
},
|
},
|
||||||
parts: parts.map((part) => ({
|
|
||||||
...part,
|
|
||||||
sessionID,
|
|
||||||
messageID,
|
|
||||||
})),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +198,7 @@ function code(seed: number, lines: number) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function turn(index: number): Message[] {
|
function turn(index: number): SessionMessageInfo[] {
|
||||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -219,7 +237,7 @@ function turn(index: number): Message[] {
|
|||||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||||
: []),
|
: []),
|
||||||
]
|
]
|
||||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||||
@@ -227,22 +245,16 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
|||||||
userMessage(sourceID, index + 1000, 120),
|
userMessage(sourceID, index + 1000, 120),
|
||||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||||
]).flat()
|
]).flat()
|
||||||
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
const messages: Record<string, SessionMessageInfo[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||||
|
|
||||||
function renderable(part: MessagePart) {
|
function currentPartIDs(message: SessionMessageInfo) {
|
||||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
if (message.type === "user") return message.text.trim() ? [`${message.id}:text:0`] : []
|
||||||
if (part.type === "text") return !!part.text?.trim()
|
if (message.type !== "assistant") return []
|
||||||
if (part.type === "reasoning") return !!part.text?.trim()
|
|
||||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentPartIDs(message: Message) {
|
|
||||||
const ordinals = { text: 0, reasoning: 0 }
|
const ordinals = { text: 0, reasoning: 0 }
|
||||||
return message.parts.flatMap((part) => {
|
return message.content.flatMap((part) => {
|
||||||
if (!renderable(part)) return []
|
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
if (part.type === "reasoning") return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
if (part.type === "tool") return [part.id]
|
||||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -295,12 +307,11 @@ export const fixture = {
|
|||||||
expected: {
|
expected: {
|
||||||
sourceTitle: "Uncommitted changes inquiry",
|
sourceTitle: "Uncommitted changes inquiry",
|
||||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||||
targetMessageIDs: targetMessages
|
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||||
.filter((message) => message.info.role === "user")
|
|
||||||
.map((message) => message.info.id),
|
|
||||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
expandedShellPartID: targetMessages
|
||||||
.callID,
|
.flatMap((message) => (message.type === "assistant" ? message.content : []))
|
||||||
|
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,12 +320,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
|||||||
const end = before
|
const end = before
|
||||||
? Math.max(
|
? Math.max(
|
||||||
0,
|
0,
|
||||||
messages.findIndex((message) => message.info.id === before),
|
messages.findIndex((message) => message.id === before),
|
||||||
)
|
)
|
||||||
: messages.length
|
: messages.length
|
||||||
const start = Math.max(0, end - limit)
|
const start = Math.max(0, end - limit)
|
||||||
return {
|
return {
|
||||||
items: messages.slice(start, end),
|
items: messages.slice(start, end),
|
||||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
cursor: start > 0 ? messages[start].id : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => {
|
|||||||
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
||||||
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
@@ -44,7 +43,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
})
|
})
|
||||||
await configureSmokePage(page, fixture.directory)
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
await waitForTimelineStable(page)
|
await waitForTimelineStable(page)
|
||||||
const scroller = timelineScroller(page)
|
const scroller = timelineScroller(page)
|
||||||
await pointAtTimeline(page)
|
await pointAtTimeline(page)
|
||||||
@@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => {
|
|||||||
|
|
||||||
test("preserves the timeline gap above the composer", async ({ page }) => {
|
test("preserves the timeline gap above the composer", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
@@ -101,7 +99,7 @@ test.describe("smoke: session timeline", () => {
|
|||||||
})
|
})
|
||||||
await configureSmokePage(page, fixture.directory)
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
await waitForTimelineStable(page)
|
await waitForTimelineStable(page)
|
||||||
const scroller = timelineScroller(page)
|
const scroller = timelineScroller(page)
|
||||||
await scroller.evaluate((element) => {
|
await scroller.evaluate((element) => {
|
||||||
@@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => {
|
|||||||
|
|
||||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
@@ -143,11 +140,11 @@ test.describe("smoke: session timeline", () => {
|
|||||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||||
)
|
)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.targetID}`)
|
||||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
|
|
||||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ destination, last }) => {
|
({ destination, last }) => {
|
||||||
@@ -262,7 +259,6 @@ test.describe("smoke: session timeline", () => {
|
|||||||
|
|
||||||
test("paints a cold session tab at the latest message", async ({ page }) => {
|
test("paints a cold session tab at the latest message", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
@@ -285,10 +281,10 @@ test.describe("smoke: session timeline", () => {
|
|||||||
},
|
},
|
||||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||||
)
|
)
|
||||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
|
||||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ destination, last }) => {
|
({ destination, last }) => {
|
||||||
const ids = new Set(destination)
|
const ids = new Set(destination)
|
||||||
@@ -341,7 +337,6 @@ test.describe("smoke: session timeline", () => {
|
|||||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||||
const errors = trackPageErrors(page)
|
const errors = trackPageErrors(page)
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
sessions: fixture.sessions,
|
sessions: fixture.sessions,
|
||||||
provider: fixture.provider,
|
provider: fixture.provider,
|
||||||
directory: fixture.directory,
|
directory: fixture.directory,
|
||||||
@@ -351,9 +346,9 @@ test.describe("smoke: session timeline", () => {
|
|||||||
await configureSmokePage(page, fixture.directory)
|
await configureSmokePage(page, fixture.directory)
|
||||||
|
|
||||||
await selectHomeProject(page, fixture.project.name)
|
await selectHomeProject(page, fixture.project.name)
|
||||||
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||||
await expectSessionReady(page)
|
await expectSessionReady(page)
|
||||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||||
@@ -742,8 +737,8 @@ async function selectHomeProject(page: Page, projectName: string) {
|
|||||||
await expect(page).toHaveURL(/\/$/)
|
await expect(page).toHaveURL(/\/$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionId}`)
|
||||||
await expectSessionTitle(page, expectedTitle)
|
await expectSessionTitle(page, expectedTitle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject"
|
|||||||
|
|
||||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
protocol: "v2",
|
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_model_selection_flow",
|
id: "proj_model_selection_flow",
|
||||||
@@ -53,7 +52,6 @@ test("creates a session in a new project and selects its model", async ({ page }
|
|||||||
findFiles: () => ["NewProject"],
|
findFiles: () => ["NewProject"],
|
||||||
})
|
})
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:model",
|
"opencode.global.dat:model",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ export function trackPageErrors(page: Page) {
|
|||||||
if (message.type() === "error") errors.push(message.text())
|
if (message.type() === "error") errors.push(message.text())
|
||||||
})
|
})
|
||||||
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`)
|
||||||
|
})
|
||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,30 @@
|
|||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
import type {
|
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
JsonValue,
|
|
||||||
PromptAgentAttachment,
|
|
||||||
PromptFileAttachment,
|
|
||||||
SessionMessageAssistant,
|
|
||||||
SessionMessageInfo,
|
|
||||||
SessionStructuredError,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
|
|
||||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
|
||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
protocol?: "v1" | "v2"
|
|
||||||
provider: unknown | (() => unknown)
|
provider: unknown | (() => unknown)
|
||||||
integrationMethods?: Record<string, unknown[]>
|
integrationMethods?: Record<string, unknown[]>
|
||||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||||
onInstanceDispose?: () => void
|
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
pageMessages: (
|
||||||
|
sessionId: string,
|
||||||
|
limit: number,
|
||||||
|
before?: string,
|
||||||
|
) => {
|
||||||
|
items: SessionMessageInfo[]
|
||||||
|
cursor?: string
|
||||||
|
}
|
||||||
vcsDiff?: unknown[]
|
vcsDiff?: unknown[]
|
||||||
messageDelay?: number
|
messageDelay?: number
|
||||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||||
message?: (sessionID: string, messageID: string) => unknown
|
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
|
||||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => OpenCodeEvent[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
todos?: (sessionID: string) => unknown[]
|
|
||||||
permissions?: unknown[] | (() => unknown[])
|
permissions?: unknown[] | (() => unknown[])
|
||||||
questions?: unknown[] | (() => unknown[])
|
|
||||||
forms?: unknown[] | (() => unknown[])
|
forms?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
@@ -46,10 +39,11 @@ type MockStreamWindow = Window & {
|
|||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ port, retry }) => {
|
({ server, retry }) => {
|
||||||
const host = window as MockStreamWindow
|
const host = window as MockStreamWindow
|
||||||
if (host.__testSseTransport || host.__mockServerStream) return
|
if (host.__testSseTransport || host.__mockServerStream) return
|
||||||
const originalFetch = window.fetch.bind(window)
|
const originalFetch = window.fetch.bind(window)
|
||||||
@@ -74,7 +68,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
|
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||||
state.connections += 1
|
state.connections += 1
|
||||||
const id = state.connections
|
const id = state.connections
|
||||||
let ended = false
|
let ended = false
|
||||||
@@ -114,7 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||||
},
|
},
|
||||||
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
|
{ server, retry: config.eventRetry },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (config.events) {
|
if (config.events) {
|
||||||
@@ -125,10 +119,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (batch.length === 0) return
|
if (batch.length === 0) return
|
||||||
pump.busy = true
|
pump.busy = true
|
||||||
void page
|
void page
|
||||||
.evaluate(
|
.evaluate((payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), batch as unknown[])
|
||||||
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
|
|
||||||
batch.map(currentEvent),
|
|
||||||
)
|
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
pump.busy = false
|
pump.busy = false
|
||||||
@@ -136,87 +127,33 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}, 50)
|
}, 50)
|
||||||
page.on("close", () => clearInterval(timer))
|
page.on("close", () => clearInterval(timer))
|
||||||
}
|
}
|
||||||
const staticRoutes: Record<string, unknown> = {
|
|
||||||
"/path": {
|
|
||||||
state: config.directory,
|
|
||||||
config: config.directory,
|
|
||||||
worktree: config.directory,
|
|
||||||
directory: config.directory,
|
|
||||||
home: "C:/OpenCode",
|
|
||||||
},
|
|
||||||
"/project": [config.project],
|
|
||||||
"/project/current": config.project,
|
|
||||||
"/agent": [{ name: "build", mode: "primary" }],
|
|
||||||
"/vcs": { branch: "main", default_branch: "main" },
|
|
||||||
"/session": config.sessions,
|
|
||||||
}
|
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
const appPort = new URL(
|
const appPort = new URL(
|
||||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||||
).port
|
).port
|
||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
if (path === "/api/event") {
|
||||||
const events = config.events?.()
|
const events = config.events?.()
|
||||||
return sse(
|
return sse(
|
||||||
route,
|
route,
|
||||||
path === "/api/event"
|
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
|
||||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
|
||||||
: [
|
|
||||||
...(path === "/global/event"
|
|
||||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
|
||||||
: []),
|
|
||||||
...(events ?? []),
|
|
||||||
],
|
|
||||||
config.eventRetry,
|
config.eventRetry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (path === "/global/health")
|
|
||||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
|
||||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
|
||||||
if (path === "/provider") return json(route, providerConfig(config))
|
|
||||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
|
||||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
|
||||||
if (legacyAuth && route.request().method() === "PUT") {
|
|
||||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
|
||||||
config.onInstanceDispose?.()
|
|
||||||
return json(route, true)
|
|
||||||
}
|
|
||||||
if (path === "/permission")
|
|
||||||
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,
|
|
||||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (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 (path === "/find/file" && config.findFiles)
|
|
||||||
return json(
|
|
||||||
route,
|
|
||||||
await config.findFiles({
|
|
||||||
query: url.searchParams.get("query") ?? "",
|
|
||||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
|
||||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (path === "/api/reference")
|
if (path === "/api/reference")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: {
|
location: {
|
||||||
directory: config.directory,
|
directory: config.directory,
|
||||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
project: {
|
||||||
|
id: (config.project as { id?: string }).id,
|
||||||
|
directory: config.directory,
|
||||||
|
canonical: config.directory,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
data: [],
|
data: [],
|
||||||
})
|
})
|
||||||
@@ -245,6 +182,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
return json(route, { location: location(config), data: currentDefaultModel(providerConfig(config)) })
|
||||||
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
if (path === "/api/integration") return json(route, { location: location(config), data: [] })
|
||||||
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/skill") return json(route, { location: location(config), data: [] })
|
||||||
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
if (path === "/api/plugin") return json(route, { location: location(config), data: [] })
|
||||||
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||||
if (path === "/api/mcp/resource")
|
if (path === "/api/mcp/resource")
|
||||||
@@ -277,7 +215,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
return json(route, {
|
||||||
|
id: (config.project as { id?: string }).id,
|
||||||
|
directory: config.directory,
|
||||||
|
canonical: config.directory,
|
||||||
|
})
|
||||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||||
if (worktree && route.request().method() === "GET")
|
if (worktree && route.request().method() === "GET")
|
||||||
return json(route, [
|
return json(route, [
|
||||||
@@ -303,18 +245,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
currentPermission,
|
currentPermission,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
if (path === "/api/question/request")
|
|
||||||
return json(route, {
|
|
||||||
location: location(config),
|
|
||||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
|
||||||
})
|
|
||||||
if (path === "/api/form/request")
|
if (path === "/api/form/request")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
location: location(config),
|
location: location(config),
|
||||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||||
})
|
})
|
||||||
if (path === "/api/vcs")
|
if (path === "/api/vcs")
|
||||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
|
||||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||||
if (path === "/api/fs/list" && config.fileList)
|
if (path === "/api/fs/list" && config.fileList)
|
||||||
@@ -352,10 +289,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
: entries,
|
: entries,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
if (path === "/api/shell" && route.request().method() === "GET")
|
||||||
|
return json(route, { location: location(config), data: [] })
|
||||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||||
if (path === "/api/session") {
|
if (path === "/api/session") {
|
||||||
|
if (route.request().method() === "POST") {
|
||||||
|
const payload = route.request().postDataJSON() as Record<string, unknown>
|
||||||
|
const created = currentSession(
|
||||||
|
{
|
||||||
|
id: "ses_mock_created",
|
||||||
|
projectID: (config.project as { id?: string }).id,
|
||||||
|
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||||
|
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||||
|
},
|
||||||
|
config.directory,
|
||||||
|
)
|
||||||
|
config.sessions.push(created)
|
||||||
|
return json(route, { data: created })
|
||||||
|
}
|
||||||
|
if (route.request().method() !== "GET") return route.fallback()
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
const parentID = url.searchParams.get("parentID")
|
const parentID = url.searchParams.get("parentID")
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
@@ -365,7 +318,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
const location = session.location as { directory?: string } | undefined
|
const location = session.location as { directory?: string } | undefined
|
||||||
return !directory || location?.directory === directory || session.directory === directory
|
return !directory || location?.directory === directory || session.directory === directory
|
||||||
})
|
})
|
||||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
.filter((session) => {
|
||||||
|
if (parentID === null) return true
|
||||||
|
if (parentID === "null") return session.parentID === undefined
|
||||||
|
return session.parentID === parentID
|
||||||
|
})
|
||||||
.filter((session) => {
|
.filter((session) => {
|
||||||
const search = url.searchParams.get("search")?.toLowerCase()
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
return (
|
return (
|
||||||
@@ -375,7 +332,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
.includes(search)
|
.includes(search)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
|
||||||
const data = ordered.slice(offset, offset + limit)
|
const data = ordered.slice(offset, offset + limit)
|
||||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||||
return json(route, {
|
return json(route, {
|
||||||
@@ -398,9 +355,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
|
||||||
}
|
|
||||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
||||||
if (sessionForm && route.request().method() === "GET") {
|
if (sessionForm && route.request().method() === "GET") {
|
||||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||||
@@ -413,15 +367,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET")
|
||||||
return json(route, { data: [] })
|
return json(route, { data: [] })
|
||||||
|
const sessionPermission = path.match(/^\/api\/session\/([^/]+)\/permission$/)?.[1]
|
||||||
|
if (sessionPermission && route.request().method() === "GET") {
|
||||||
|
const permissions = typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])
|
||||||
|
return json(route, {
|
||||||
|
data: permissions.map(currentPermission).filter((permission) => permission.sessionID === sessionPermission),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
|
||||||
return json(route, true)
|
|
||||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
|
||||||
return json(route, true)
|
|
||||||
if (
|
if (
|
||||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
route.request().method() === "POST"
|
route.request().method() === "POST"
|
||||||
) {
|
) {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
@@ -429,10 +386,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
}
|
}
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
|
||||||
if (emptyList.has(path)) return json(route, [])
|
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
|
||||||
|
|
||||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||||
if (currentSessionMatch) {
|
if (currentSessionMatch) {
|
||||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||||
@@ -442,69 +395,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||||
if (currentMessageMatch) {
|
|
||||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
|
||||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
|
||||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
|
||||||
return json(route, { data: currentMessage(message) })
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
|
||||||
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
|
|
||||||
|
|
||||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
|
||||||
if (projectMatch) return json(route, config.project)
|
|
||||||
|
|
||||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
|
||||||
if (messageMatch) {
|
if (messageMatch) {
|
||||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
const message =
|
||||||
|
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
|
||||||
|
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
|
||||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||||
return json(route, message)
|
return json(route, { data: message })
|
||||||
}
|
}
|
||||||
|
|
||||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
|
||||||
|
|
||||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
|
||||||
if (currentMessagesMatch) {
|
|
||||||
const token = url.searchParams.get("cursor") ?? undefined
|
|
||||||
const before = token ? cursors.get(token) : undefined
|
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
|
||||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
|
||||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
|
||||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
|
||||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
|
||||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
|
||||||
return json(route, {
|
|
||||||
data: pageData.items.map(currentMessage).reverse(),
|
|
||||||
cursor: { next: cursor },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
const token = url.searchParams.get("cursor") ?? undefined
|
||||||
const before = token ? cursors.get(token) : undefined
|
const before = token ? cursors.get(token) : undefined
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
|
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||||
if (!pageData.cursor) return json(route, pageData.items)
|
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||||
const cursor = `cursor_${++nextCursor}`
|
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||||
cursors.set(cursor, pageData.cursor)
|
return json(route, {
|
||||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||||
|
cursor: { next: cursor },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
if (url.port === targetPort && targetPort !== appPort)
|
||||||
|
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -522,13 +443,21 @@ function providerConfig(config: MockServerConfig) {
|
|||||||
|
|
||||||
function currentProviders(value: unknown) {
|
function currentProviders(value: unknown) {
|
||||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||||
return value.all
|
const connected = new Set(
|
||||||
.filter(record)
|
Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [],
|
||||||
.flatMap((provider) =>
|
)
|
||||||
typeof provider.id === "string" && typeof provider.name === "string"
|
return value.all.filter(record).flatMap((provider) =>
|
||||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
typeof provider.id === "string" && typeof provider.name === "string"
|
||||||
: [],
|
? [
|
||||||
)
|
{
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
package: provider.id,
|
||||||
|
activation: connected.has(provider.id) ? "enabled" : "auto",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentModels(value: unknown) {
|
function currentModels(value: unknown) {
|
||||||
@@ -584,7 +513,7 @@ function currentDefaultModel(value: unknown) {
|
|||||||
function currentPermission(value: unknown) {
|
function currentPermission(value: unknown) {
|
||||||
const permission = value as Record<string, unknown>
|
const permission = value as Record<string, unknown>
|
||||||
if (permission.action) return permission
|
if (permission.action) return permission
|
||||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined
|
||||||
return {
|
return {
|
||||||
id: permission.id,
|
id: permission.id,
|
||||||
sessionID: permission.sessionID,
|
sessionID: permission.sessionID,
|
||||||
@@ -593,7 +522,9 @@ function currentPermission(value: unknown) {
|
|||||||
save: permission.always,
|
save: permission.always,
|
||||||
metadata: permission.metadata,
|
metadata: permission.metadata,
|
||||||
source:
|
source:
|
||||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
tool?.messageID && (tool.id || tool.callID)
|
||||||
|
? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID }
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,202 +565,6 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function currentMessage(value: unknown): SessionMessageInfo {
|
|
||||||
if (isCurrentMessage(value)) return value
|
|
||||||
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
|
|
||||||
|
|
||||||
const info = value.info
|
|
||||||
const parts = value.parts.filter(record)
|
|
||||||
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
|
|
||||||
throw new Error("Invalid legacy message fixture")
|
|
||||||
|
|
||||||
const time = {
|
|
||||||
created: info.time.created,
|
|
||||||
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
|
|
||||||
}
|
|
||||||
if (info.role === "user") {
|
|
||||||
return {
|
|
||||||
id: info.id,
|
|
||||||
type: "user",
|
|
||||||
time: { created: time.created },
|
|
||||||
text: parts
|
|
||||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
|
||||||
.join("\n"),
|
|
||||||
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
|
|
||||||
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: info.id,
|
|
||||||
type: "assistant",
|
|
||||||
time,
|
|
||||||
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
|
|
||||||
model: {
|
|
||||||
id: typeof info.modelID === "string" ? info.modelID : "model",
|
|
||||||
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
|
|
||||||
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
|
|
||||||
},
|
|
||||||
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
|
|
||||||
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
|
|
||||||
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
|
|
||||||
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
|
|
||||||
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
|
|
||||||
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
|
|
||||||
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
|
|
||||||
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
|
|
||||||
const source = record(part.source) ? part.source : undefined
|
|
||||||
const sourceText = source && record(source.text) ? source.text : undefined
|
|
||||||
const mention = mentionFrom(sourceText)
|
|
||||||
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
data,
|
|
||||||
mime: part.mime,
|
|
||||||
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
|
|
||||||
...(typeof part.filename === "string" ? { name: part.filename } : {}),
|
|
||||||
...(mention ? { mention } : {}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
|
||||||
if (typeof part.name !== "string") return []
|
|
||||||
const mention = mentionFrom(record(part.source) ? part.source : undefined)
|
|
||||||
return [{ name: part.name, ...(mention ? { mention } : {}) }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
|
||||||
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
|
||||||
return
|
|
||||||
return { text: value.value, start: value.start, end: value.end }
|
|
||||||
}
|
|
||||||
|
|
||||||
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
|
||||||
if (part.type === "text" && typeof part.text === "string")
|
|
||||||
return [
|
|
||||||
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
|
||||||
]
|
|
||||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
|
||||||
const time = record(part.time) ? part.time : undefined
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "reasoning",
|
|
||||||
text: part.text,
|
|
||||||
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
|
|
||||||
...(time && typeof time.start === "number"
|
|
||||||
? {
|
|
||||||
time: {
|
|
||||||
created: time.start,
|
|
||||||
...(typeof time.end === "number" ? { completed: time.end } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
|
|
||||||
return []
|
|
||||||
|
|
||||||
const state = part.state
|
|
||||||
const time = record(state.time) ? state.time : undefined
|
|
||||||
const toolTime = {
|
|
||||||
created: time && typeof time.start === "number" ? time.start : created,
|
|
||||||
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
|
|
||||||
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
|
|
||||||
}
|
|
||||||
const input = jsonRecord(state.input) ?? {}
|
|
||||||
const metadata = jsonRecord(state.metadata)
|
|
||||||
const base = {
|
|
||||||
type: "tool" as const,
|
|
||||||
id: typeof part.callID === "string" ? part.callID : part.id,
|
|
||||||
name: part.tool,
|
|
||||||
time: toolTime,
|
|
||||||
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
|
|
||||||
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
|
|
||||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
|
||||||
}
|
|
||||||
if (state.status === "pending")
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (state.status === "completed")
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
state: {
|
|
||||||
status: "completed",
|
|
||||||
input,
|
|
||||||
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
|
|
||||||
...(metadata ? { metadata } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (state.status === "error")
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...base,
|
|
||||||
state: {
|
|
||||||
status: "error",
|
|
||||||
input,
|
|
||||||
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
|
|
||||||
...(metadata ? { metadata } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
|
|
||||||
}
|
|
||||||
|
|
||||||
function structuredError(value: unknown): SessionStructuredError | undefined {
|
|
||||||
if (typeof value === "string") return { type: "Error", message: value }
|
|
||||||
if (!record(value)) return
|
|
||||||
if (typeof value.type === "string" && typeof value.message === "string")
|
|
||||||
return { type: value.type, message: value.message }
|
|
||||||
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
|
|
||||||
return { type: value.name, message: value.data.message }
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
|
|
||||||
if (!record(value) || !record(value.cache)) return
|
|
||||||
if (
|
|
||||||
typeof value.input !== "number" ||
|
|
||||||
typeof value.output !== "number" ||
|
|
||||||
typeof value.reasoning !== "number" ||
|
|
||||||
typeof value.cache.read !== "number" ||
|
|
||||||
typeof value.cache.write !== "number"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
input: value.input,
|
|
||||||
output: value.output,
|
|
||||||
reasoning: value.reasoning,
|
|
||||||
cache: { read: value.cache.read, write: value.cache.write },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
|
|
||||||
if (
|
|
||||||
value === "stop" ||
|
|
||||||
value === "length" ||
|
|
||||||
value === "tool-calls" ||
|
|
||||||
value === "content-filter" ||
|
|
||||||
value === "error" ||
|
|
||||||
value === "unknown"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||||||
if (!record(value)) return
|
if (!record(value)) return
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
@@ -871,18 +606,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
|||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentEvent(input: unknown) {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
@@ -27,7 +28,7 @@ export type SseEventOptions = {
|
|||||||
marker?: string
|
marker?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SseTransport<T> = {
|
export type SseTransport<T extends OpenCodeEvent> = {
|
||||||
server: string
|
server: string
|
||||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||||
@@ -55,7 +56,7 @@ type BrowserTransport = Window & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function installSseTransport<T>(
|
export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEvent>(
|
||||||
page: Page,
|
page: Page,
|
||||||
options: { server: string; retry?: number },
|
options: { server: string; retry?: number },
|
||||||
): Promise<SseTransport<T>> {
|
): Promise<SseTransport<T>> {
|
||||||
@@ -93,21 +94,6 @@ export async function installSseTransport<T>(
|
|||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].join("")
|
||||||
const currentEvent = (input: unknown) => {
|
|
||||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
|
||||||
const envelope = input as { directory?: string; payload?: unknown }
|
|
||||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
|
||||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
|
||||||
if (!payload.type) return input
|
|
||||||
return {
|
|
||||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
|
||||||
created: Date.now(),
|
|
||||||
type: payload.type,
|
|
||||||
data: payload.properties ?? {},
|
|
||||||
location:
|
|
||||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const acknowledge = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
@@ -155,13 +141,14 @@ export async function installSseTransport<T>(
|
|||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => {
|
const encoded = input.deliveries.map((delivery) => ({
|
||||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
delivery,
|
||||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
payload: delivery.payload,
|
||||||
})
|
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||||
|
}))
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
const bytes = encoder.encode(encoded.map((item) => new TextDecoder().decode(item.bytes)).join(""))
|
||||||
connection.controller.enqueue(bytes)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
}
|
}
|
||||||
@@ -266,15 +253,11 @@ export async function installSseTransport<T>(
|
|||||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||||
},
|
},
|
||||||
heartbeat(eventOptions) {
|
heartbeat(eventOptions) {
|
||||||
|
const bytes = new TextEncoder().encode(": heartbeat\n\n")
|
||||||
return command({
|
return command({
|
||||||
type: "send",
|
type: "raw",
|
||||||
deliveries: [
|
bytes: Array.from(bytes),
|
||||||
{
|
marker: eventOptions?.marker,
|
||||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
|
||||||
options: eventOptions,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
burst: false,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
writeRaw(value, cuts, marker) {
|
writeRaw(value, cuts, marker) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Page, TestInfo } from "@playwright/test"
|
import type { Page, TestInfo } from "@playwright/test"
|
||||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
import { stabilityPlan, type VisualStabilityOptions } from "./visual-stability/invariant"
|
||||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||||
@@ -25,22 +25,19 @@ export async function markVisualStability(page: Page, label: string) {
|
|||||||
await markVisualProbe(page, label)
|
await markVisualProbe(page, label)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
export function analyzeVisualStability(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
return analyzeVisualObservations(trace.samples, stabilityPlan(options))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analyzeVisualStabilityByMarker(
|
export function analyzeVisualStabilityByMarker(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||||
trace: VisualStabilityTrace,
|
return analyzeVisualTraceByMarker(trace, stabilityPlan(options))
|
||||||
options: LegacyVisualStabilityOptions = {},
|
|
||||||
) {
|
|
||||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function expectVisualStability(
|
export async function expectVisualStability(
|
||||||
testInfo: TestInfo,
|
testInfo: TestInfo,
|
||||||
name: string,
|
name: string,
|
||||||
trace: VisualStabilityTrace,
|
trace: VisualStabilityTrace,
|
||||||
options: LegacyVisualStabilityOptions = {},
|
options: VisualStabilityOptions = {},
|
||||||
) {
|
) {
|
||||||
await reportVisualStability(
|
await reportVisualStability(
|
||||||
testInfo,
|
testInfo,
|
||||||
@@ -49,6 +46,6 @@ export async function expectVisualStability(
|
|||||||
...trace,
|
...trace,
|
||||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||||
},
|
},
|
||||||
legacyVisualPlan(options),
|
stabilityPlan(options),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export type VisualPlan<RegionName extends string = string> = {
|
|||||||
aggregateMotion?: boolean
|
aggregateMotion?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
export type VisualStabilityOptions<RegionName extends string = string> = {
|
||||||
flow?: RegionName[]
|
flow?: RegionName[]
|
||||||
motionTolerance?: number
|
motionTolerance?: number
|
||||||
opacityFloor?: number
|
opacityFloor?: number
|
||||||
@@ -58,8 +58,8 @@ export function visualPlan<const Regions extends Record<string, VisualRegionDefi
|
|||||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function legacyVisualPlan<RegionName extends string>(
|
export function stabilityPlan<RegionName extends string>(
|
||||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
options: VisualStabilityOptions<RegionName> = {},
|
||||||
): VisualPlan<RegionName> {
|
): VisualPlan<RegionName> {
|
||||||
const inferred =
|
const inferred =
|
||||||
options.inferRequired === false
|
options.inferRequired === false
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
data-new-layout
|
|
||||||
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||||
>
|
>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@@ -74,7 +74,6 @@
|
|||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@tanstack/solid-virtual": "catalog:",
|
"@tanstack/solid-virtual": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
|||||||
@@ -2,13 +2,6 @@
|
|||||||
var key = "opencode-theme-id"
|
var key = "opencode-theme-id"
|
||||||
var themeId = localStorage.getItem(key) || "oc-2"
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
if (themeId === "oc-1") {
|
|
||||||
themeId = "oc-2"
|
|
||||||
localStorage.setItem(key, themeId)
|
|
||||||
localStorage.removeItem("opencode-theme-css-light")
|
|
||||||
localStorage.removeItem("opencode-theme-css-dark")
|
|
||||||
}
|
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
var mode = isDark ? "dark" : "light"
|
var mode = isDark ? "dark" : "light"
|
||||||
|
|||||||
+22
-76
@@ -12,7 +12,6 @@ import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchPara
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
|
||||||
createMemo,
|
createMemo,
|
||||||
createRenderEffect,
|
createRenderEffect,
|
||||||
ErrorBoundary,
|
ErrorBoundary,
|
||||||
@@ -32,16 +31,15 @@ import { LayoutProvider } from "@/context/layout"
|
|||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { LocationProvider } from "@/context/location"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { SessionUIProvider } from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { requireServerKey } from "./utils/session-route"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
|
|
||||||
import { TargetSessionRouteContent } from "@/pages/session"
|
import { TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { Home } from "@/pages/home"
|
import { Home } from "@/pages/home"
|
||||||
@@ -49,36 +47,17 @@ import { ServerProvider } from "./context/server"
|
|||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
const DirectoryDraftRedirect = () => {
|
|
||||||
const params = useParams()
|
|
||||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
|
||||||
const server = useServers()
|
|
||||||
const tabs = useTabs()
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (search.draftId || !tabs.ready()) return
|
|
||||||
const directory = decode64(params.dir)
|
|
||||||
if (!directory) return
|
|
||||||
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
|
|
||||||
})
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function TargetServerRoute(props: ParentProps) {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string }>()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => {
|
const conn = createMemo(() =>
|
||||||
const key = requireServerKey(params.serverKey)
|
global.servers.list().find((item) => ServerConnection.key(item) === requireServerKey(params.serverKey)),
|
||||||
return global.servers.list().find((item) => ServerConnection.key(item) === key)
|
)
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// Owns the server-identity remount. Session changes must not remount this subtree.
|
// Owns the server-identity remount. Session changes must not remount this subtree.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={conn()} keyed>
|
||||||
<Show when={conn()} keyed>
|
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
||||||
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -87,14 +66,12 @@ function DraftRoute() {
|
|||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show
|
||||||
<Show
|
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
||||||
when={tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)}
|
keyed
|
||||||
keyed
|
fallback={tabs.ready() && <Navigate href="/" />}
|
||||||
fallback={<Navigate href="/" />}
|
>
|
||||||
>
|
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
||||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -109,13 +86,13 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
{(conn) => (
|
{(conn) => (
|
||||||
<ServerProvider conn={conn}>
|
<ServerProvider conn={conn}>
|
||||||
<ModelsProvider directory={props.draft.directory}>
|
<ModelsProvider directory={props.draft.directory}>
|
||||||
<SDKProvider directory={props.draft.directory}>
|
<LocationProvider directory={props.draft.directory}>
|
||||||
<DirectoryDataProvider directory={props.draft.directory} server={props.draft.server}>
|
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
<NewSession draftId={props.draft.draftID} />
|
<NewSession draftId={props.draft.draftID} />
|
||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</SessionUIProvider>
|
||||||
</SDKProvider>
|
</LocationProvider>
|
||||||
</ModelsProvider>
|
</ModelsProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)}
|
)}
|
||||||
@@ -166,10 +143,9 @@ function QueryProvider(props: ParentProps) {
|
|||||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function BodyDesignClass() {
|
function BodyTypography() {
|
||||||
createRenderEffect(() => {
|
createRenderEffect(() => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
document.body.toggleAttribute("data-new-layout", true)
|
|
||||||
document.body.classList.remove("text-12-regular")
|
document.body.classList.remove("text-12-regular")
|
||||||
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
|
||||||
})
|
})
|
||||||
@@ -265,15 +241,13 @@ export function AppInterface(props: {
|
|||||||
canonicalLocalServer?: ServerConnection.Key
|
canonicalLocalServer?: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
|
||||||
startup?: Promise<void>
|
|
||||||
}) {
|
}) {
|
||||||
// The visual layout lives in the router root so it remains mounted across
|
// The visual layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
// providers beneath it.
|
// providers beneath it.
|
||||||
const Root = (rootProps: ParentProps) => (
|
const Root = (rootProps: ParentProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<BodyDesignClass />
|
<BodyTypography />
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<DesktopCommands />
|
<DesktopCommands />
|
||||||
<HighlightsProvider>
|
<HighlightsProvider>
|
||||||
@@ -293,7 +267,6 @@ export function AppInterface(props: {
|
|||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<Dynamic component={props.router ?? Router} root={Root}>
|
<Dynamic component={props.router ?? Router} root={Root}>
|
||||||
{/* Proper Routes */}
|
|
||||||
<Route component={AppLayout}>
|
<Route component={AppLayout}>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route
|
<Route
|
||||||
@@ -306,36 +279,9 @@ export function AppInterface(props: {
|
|||||||
/>
|
/>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* Legacy Routes */}
|
|
||||||
<Route>
|
|
||||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
|
||||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
|
||||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
|
||||||
</Route>
|
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
</ServersProvider>
|
</ServersProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacySessionRedirect() {
|
|
||||||
const server = useServers()
|
|
||||||
const tabs = useTabs()
|
|
||||||
const params = useParams<{ id: string }>()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={tabs.ready()}>
|
|
||||||
<Navigate
|
|
||||||
href={sessionHref(
|
|
||||||
legacySessionServer(
|
|
||||||
tabs.store.filter((item) => item.type === "session"),
|
|
||||||
params.id,
|
|
||||||
ServerConnection.key(server.list[0]!),
|
|
||||||
),
|
|
||||||
params.id,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
@@ -16,10 +13,8 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { useIntegrations } from "@/hooks/use-integrations"
|
import { useIntegrations } from "@/hooks/use-integrations"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
@@ -47,8 +42,6 @@ export const DialogConnectProvider: Component<{
|
|||||||
const fallback = useProviderConnectController()
|
const fallback = useProviderConnectController()
|
||||||
const controller = props.controller ?? fallback
|
const controller = props.controller ?? fallback
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
|
||||||
const reset = controller.back
|
const reset = controller.back
|
||||||
const back = { current: reset }
|
const back = { current: reset }
|
||||||
let focusHost: HTMLDivElement | undefined
|
let focusHost: HTMLDivElement | undefined
|
||||||
@@ -62,7 +55,7 @@ export const DialogConnectProvider: Component<{
|
|||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.selected() === CUSTOM_ID}>
|
<Match when={controller.selected() === CUSTOM_ID}>
|
||||||
<CustomProviderForm autofocus={!newLayout()} />
|
<CustomProviderForm autofocus={false} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||||
{(provider) => (
|
{(provider) => (
|
||||||
@@ -75,140 +68,42 @@ export const DialogConnectProvider: Component<{
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>
|
||||||
<ProviderPicker
|
<ProviderPicker directory={props.directory} onSelect={select} onPrepare={holdFocus} />
|
||||||
directory={props.directory}
|
|
||||||
onSelect={select}
|
|
||||||
onPrepare={newLayout() ? holdFocus : undefined}
|
|
||||||
/>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<DialogV2
|
||||||
when={newLayout()}
|
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
fallback={
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
<Dialog
|
|
||||||
class="h-full"
|
|
||||||
transition
|
|
||||||
title={
|
|
||||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => back.current()}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Content />
|
|
||||||
</Dialog>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<DialogV2
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
<Show
|
||||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
when={controller.selected()}
|
||||||
>
|
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
||||||
<DialogHeader closeLabel={language.t("common.close")}>
|
>
|
||||||
<Show
|
<button
|
||||||
when={controller.selected()}
|
type="button"
|
||||||
fallback={<DialogTitle>{language.t("command.provider.connect")}</DialogTitle>}
|
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => back.current()}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
>
|
>
|
||||||
<button
|
<Icon name="arrow-left" size="small" />
|
||||||
type="button"
|
</button>
|
||||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
</Show>
|
||||||
onClick={() => back.current()}
|
</DialogHeader>
|
||||||
aria-label={language.t("common.goBack")}
|
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
||||||
>
|
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
||||||
<Icon name="arrow-left" size="small" />
|
<Content />
|
||||||
</button>
|
</div>
|
||||||
</Show>
|
</DialogBody>
|
||||||
</DialogHeader>
|
</DialogV2>
|
||||||
<DialogBody class="min-h-0 flex-1 overflow-hidden px-2 pb-2">
|
|
||||||
<div ref={focusHost} tabIndex={-1} class="flex min-h-0 flex-1 flex-col outline-none">
|
|
||||||
<Content />
|
|
||||||
</div>
|
|
||||||
</DialogBody>
|
|
||||||
</DialogV2>
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
||||||
const settings = useSettings()
|
|
||||||
if (settings.general.newLayoutDesigns())
|
|
||||||
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
|
|
||||||
const integrations = useIntegrations(() => props.directory)
|
|
||||||
const language = useLanguage()
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
|
||||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
|
||||||
const note = (id: string) => {
|
|
||||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
|
||||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
|
||||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
|
||||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<List
|
|
||||||
class="px-3"
|
|
||||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
|
||||||
emptyMessage={language.t("dialog.provider.empty")}
|
|
||||||
activeIcon="plus-small"
|
|
||||||
key={(x) => x?.id}
|
|
||||||
items={() => {
|
|
||||||
language.locale()
|
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...integrations.list()]
|
|
||||||
}}
|
|
||||||
filterKeys={["id", "name"]}
|
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
|
||||||
sortBy={(a, b) => {
|
|
||||||
if (a.id === CUSTOM_ID) return -1
|
|
||||||
if (b.id === CUSTOM_ID) return 1
|
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}}
|
|
||||||
sortGroupsBy={(a, b) => {
|
|
||||||
const popular = popularGroup()
|
|
||||||
if (a.category === popular && b.category !== popular) return -1
|
|
||||||
if (b.category === popular && a.category !== popular) return 1
|
|
||||||
return 0
|
|
||||||
}}
|
|
||||||
onSelect={(x) => {
|
|
||||||
if (!x) return
|
|
||||||
props.onSelect(x.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
|
||||||
<span>{i.name}</span>
|
|
||||||
<Show when={i.id === "opencode"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === CUSTOM_ID}>
|
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "opencode"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
|
||||||
<Show when={i.id === "opencode-go"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderPickerV2(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
|
|
||||||
const integrations = useIntegrations(() => props.directory)
|
const integrations = useIntegrations(() => props.directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -362,11 +257,8 @@ function ProviderConnection(props: {
|
|||||||
setBack: (handler: () => void) => void
|
setBack: (handler: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
|
||||||
const providers = useProviders(() => props.directory)
|
const providers = useProviders(() => props.directory)
|
||||||
const directory = () => props.directory ?? decode64(params.dir)
|
const directory = () => props.directory ?? decode64(params.dir)
|
||||||
|
|
||||||
@@ -386,10 +278,7 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(() => ({
|
const provider = createMemo(() => ({
|
||||||
id: props.provider,
|
id: props.provider,
|
||||||
name:
|
name:
|
||||||
providers.all().get(props.provider)?.name ??
|
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
|
||||||
controller.integration()?.name ??
|
|
||||||
props.provider,
|
|
||||||
}))
|
}))
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
@@ -534,15 +423,6 @@ function ProviderConnection(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
|
||||||
function handleKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") return
|
|
||||||
listRef?.onKeyDown(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||||
controller.auth.reset()
|
controller.auth.reset()
|
||||||
@@ -554,66 +434,34 @@ function ProviderConnection(props: {
|
|||||||
props.setBack(goBack)
|
props.setBack(goBack)
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<For each={controller.methods()}>
|
|
||||||
{(item, index) => {
|
|
||||||
const details = () => methodDetails(item)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
|
||||||
onClick={() => void controller.auth.select(index())}
|
|
||||||
>
|
|
||||||
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
|
||||||
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
|
||||||
</span>
|
|
||||||
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
|
||||||
<Show when={details().hint}>
|
|
||||||
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div class="flex flex-col gap-2">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
{language.t("provider.connect.selectMethod", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex flex-col">
|
||||||
<List
|
<For each={controller.methods()}>
|
||||||
class="px-3"
|
{(item, index) => {
|
||||||
ref={(ref) => {
|
const details = () => methodDetails(item)
|
||||||
listRef = ref
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex h-9 w-full items-center gap-2 rounded-md px-3 text-left text-[13px] leading-5 tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||||
|
onClick={() => void controller.auth.select(index())}
|
||||||
|
>
|
||||||
|
<span class="flex h-2 w-4 shrink-0 items-center justify-center rounded-[1px] bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]">
|
||||||
|
<span class="hidden h-0.5 w-2.5 bg-v2-icon-icon-base group-hover:block group-focus-visible:block" />
|
||||||
|
</span>
|
||||||
|
<span class="font-[530] text-v2-text-text-base">{details().label}</span>
|
||||||
|
<Show when={details().hint}>
|
||||||
|
{(hint) => <span class="font-[440] text-v2-text-text-muted">{hint()}</span>}
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
items={controller.methods}
|
</For>
|
||||||
key={(m) => m?.label ?? m?.type}
|
|
||||||
onSelect={async (selected, index) => {
|
|
||||||
if (!selected) return
|
|
||||||
void controller.auth.select(index)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="w-full flex items-center gap-x-2">
|
|
||||||
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
|
||||||
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
|
||||||
</div>
|
|
||||||
<span>{methodLabel(i)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +474,6 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!newLayout()) return
|
|
||||||
apiKey?.focus({ preventScroll: true })
|
apiKey?.focus({ preventScroll: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -646,97 +493,54 @@ function ProviderConnection(props: {
|
|||||||
await controller.auth.connectKey(apiKey)
|
await controller.auth.connectKey(apiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
<Show
|
|
||||||
when={provider().id === "opencode"}
|
|
||||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
|
||||||
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
|
||||||
<div>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
|
||||||
<ExternalLink
|
|
||||||
href="https://opencode.ai/zen"
|
|
||||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
|
||||||
>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
||||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
<TextInputV2
|
|
||||||
ref={apiKey}
|
|
||||||
class="!w-full"
|
|
||||||
name="apiKey"
|
|
||||||
data-input="provider-api-key"
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
value={formStore.value}
|
|
||||||
invalid={formStore.error !== undefined}
|
|
||||||
aria-describedby={formStore.error ? errorID : undefined}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Show when={formStore.error}>
|
|
||||||
{(error) => (
|
|
||||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
|
||||||
{language.t("common.continue")}
|
|
||||||
</ButtonV2>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
<Switch>
|
<Show
|
||||||
<Match when={provider().id === "opencode"}>
|
when={provider().id === "opencode"}
|
||||||
<div class="flex flex-col gap-4">
|
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
|
>
|
||||||
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
|
<div class="flex flex-col gap-5">
|
||||||
<div class="text-14-regular text-text-base">
|
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
|
||||||
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
|
||||||
<ExternalLink href="https://opencode.ai/zen" tabIndex={-1}>
|
<div>
|
||||||
{language.t("provider.connect.opencodeZen.visit.link")}
|
{language.t("provider.connect.opencodeZen.visit.prefix")}
|
||||||
</ExternalLink>
|
<ExternalLink
|
||||||
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
href="https://opencode.ai/zen"
|
||||||
|
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||||
|
>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.link")}
|
||||||
|
</ExternalLink>
|
||||||
|
{language.t("provider.connect.opencodeZen.visit.suffix")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
|
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||||
|
<TextInputV2
|
||||||
|
ref={apiKey}
|
||||||
|
class="!w-full"
|
||||||
|
name="apiKey"
|
||||||
|
data-input="provider-api-key"
|
||||||
|
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
||||||
|
value={formStore.value}
|
||||||
|
invalid={formStore.error !== undefined}
|
||||||
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Match>
|
</Show>
|
||||||
<Match when={true}>
|
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||||
<div class="text-14-regular text-text-base">
|
|
||||||
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
|
||||||
<TextField
|
|
||||||
autofocus={!newLayout()}
|
|
||||||
ref={apiKey}
|
|
||||||
type="text"
|
|
||||||
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
|
||||||
placeholder={language.t("provider.connect.apiKey.placeholder")}
|
|
||||||
name="apiKey"
|
|
||||||
value={formStore.value}
|
|
||||||
onChange={(v) => setFormStore("value", v)}
|
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
|
||||||
error={formStore.error}
|
|
||||||
/>
|
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
|
||||||
{language.t("common.continue")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -751,7 +555,6 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!newLayout()) return
|
|
||||||
codeInput?.focus({ preventScroll: true })
|
codeInput?.focus({ preventScroll: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -771,73 +574,41 @@ function ProviderConnection(props: {
|
|||||||
setFormStore("error", await controller.auth.completeCode(code))
|
setFormStore("error", await controller.auth.completeCode(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
|
||||||
<div>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
|
||||||
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
|
||||||
</ExternalLink>
|
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
|
||||||
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
|
||||||
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
|
||||||
<TextInputV2
|
|
||||||
ref={codeInput}
|
|
||||||
class="!w-full"
|
|
||||||
name="code"
|
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
|
||||||
value={formStore.value}
|
|
||||||
invalid={formStore.error !== undefined}
|
|
||||||
aria-describedby={formStore.error ? errorID : undefined}
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck={false}
|
|
||||||
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<Show when={formStore.error}>
|
|
||||||
{(error) => (
|
|
||||||
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
|
||||||
{error()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<ButtonV2 type="submit" variant="contrast">
|
|
||||||
{language.t("common.continue")}
|
|
||||||
</ButtonV2>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
<div class="text-14-regular text-text-base">
|
<div>
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<ExternalLink href={controller.authorization()!.url}>
|
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||||
<TextField
|
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
|
||||||
autofocus={!newLayout()}
|
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
|
||||||
ref={codeInput}
|
<TextInputV2
|
||||||
type="text"
|
ref={codeInput}
|
||||||
label={language.t("provider.connect.oauth.code.label", {
|
class="!w-full"
|
||||||
method: controller.currentMethod()?.label ?? "",
|
name="code"
|
||||||
})}
|
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
||||||
placeholder={language.t("provider.connect.oauth.code.placeholder")}
|
value={formStore.value}
|
||||||
name="code"
|
invalid={formStore.error !== undefined}
|
||||||
value={formStore.value}
|
aria-describedby={formStore.error ? errorID : undefined}
|
||||||
onChange={(v) => setFormStore("value", v)}
|
autocomplete="off"
|
||||||
validationState={formStore.error ? "invalid" : undefined}
|
spellcheck={false}
|
||||||
error={formStore.error}
|
onInput={(event) => setFormStore("value", event.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
</label>
|
||||||
|
<Show when={formStore.error}>
|
||||||
|
{(error) => (
|
||||||
|
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
|
||||||
|
{error()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
<ButtonV2 type="submit" variant="contrast">
|
||||||
{language.t("common.continue")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</ButtonV2>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -877,19 +648,10 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-3"}>
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<div class={newLayout() ? "flex h-10 shrink-0 items-start gap-2 px-3" : "flex items-center gap-4 px-2.5"}>
|
<div class="flex h-10 shrink-0 items-start gap-2 px-3">
|
||||||
<ProviderIcon
|
<ProviderIcon id={props.provider} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
id={props.provider}
|
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
|
||||||
class={newLayout() ? "mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" : "size-5 shrink-0 icon-strong-base"}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class={
|
|
||||||
newLayout()
|
|
||||||
? "text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base"
|
|
||||||
: "text-16-medium text-text-strong"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match
|
<Match
|
||||||
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
|
when={props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")}
|
||||||
@@ -900,12 +662,8 @@ function ProviderConnection(props: {
|
|||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class={newLayout() ? "flex min-h-0 flex-1 flex-col" : "flex flex-col gap-6 px-2.5 pb-10"}>
|
<div class="flex min-h-0 flex-1 flex-col">
|
||||||
<div
|
<div>
|
||||||
onKeyDown={handleKey}
|
|
||||||
tabIndex={newLayout() ? undefined : 0}
|
|
||||||
autofocus={!newLayout() && controller.methodIndex() === undefined ? true : undefined}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={controller.loading()}>
|
<Match when={controller.loading()}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast"
|
|||||||
import { batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
|
|
||||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore<FormState>({
|
const [form, setForm] = createStore<FormState>({
|
||||||
@@ -116,8 +116,9 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
const output = validateCustomProvider({
|
const output = validateCustomProvider({
|
||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
// TODO: Restore disabled-provider validation when V2 exposes config reads.
|
||||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
disabledProviders: [],
|
||||||
|
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { For, Show, createSignal, startTransition } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
import { ServerConnection } from "@/context/servers"
|
import { ServerConnection } from "@/context/servers"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { LocationProvider } from "@/context/location"
|
||||||
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { createEditProjectModel } from "./edit-project"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
import { ProjectSettingsExtensions } from "./project-settings-extensions"
|
||||||
@@ -22,9 +22,9 @@ import "./dialog-edit-project-v2.css"
|
|||||||
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
return (
|
return (
|
||||||
<SettingsServerDataScope server={props.server}>
|
<SettingsServerDataScope server={props.server}>
|
||||||
<SDKProvider directory={props.project.worktree}>
|
<LocationProvider directory={props.project.worktree}>
|
||||||
<ProjectSettingsDialog project={props.project} server={props.server} />
|
<ProjectSettingsDialog project={props.project} server={props.server} />
|
||||||
</SDKProvider>
|
</LocationProvider>
|
||||||
</SettingsServerDataScope>
|
</SettingsServerDataScope>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Component, createMemo } from "solid-js"
|
import { Component, createMemo } from "solid-js"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useSync } from "@/context/sync"
|
import { useData } from "@/context/server"
|
||||||
import { useSDK } from "@/context/sdk"
|
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
|
||||||
import type { TextPart as SDKTextPart } from "@/types"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
|
import { normalizeSessionMessages } from "@/utils/session-message"
|
||||||
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
|
import { useServer } from "@/context/server"
|
||||||
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
|
||||||
interface ForkableMessage {
|
interface ForkableMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -25,29 +28,27 @@ function formatTime(date: Date): string {
|
|||||||
export const DialogFork: Component = () => {
|
export const DialogFork: Component = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
const sdk = useSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const location = useWorkspaceLocation()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
|
||||||
const messages = createMemo((): ForkableMessage[] => {
|
const messages = createMemo((): ForkableMessage[] => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return []
|
if (!sessionID) return []
|
||||||
|
|
||||||
const msgs = sync().data.message[sessionID] ?? []
|
const msgs = data.session.message.list(sessionID)
|
||||||
const result: ForkableMessage[] = []
|
const result: ForkableMessage[] = []
|
||||||
|
|
||||||
for (const message of msgs) {
|
for (const message of msgs) {
|
||||||
if (message.role !== "user") continue
|
if (message.type !== "user" || !message.text) continue
|
||||||
|
|
||||||
const parts = sync().data.part[message.id] ?? []
|
|
||||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
|
||||||
if (!textPart) continue
|
|
||||||
|
|
||||||
result.push({
|
result.push({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
text: message.text.replace(/\n/g, " ").slice(0, 200),
|
||||||
time: formatTime(new Date(message.time.created)),
|
time: formatTime(new Date(message.time.created)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -60,20 +61,22 @@ export const DialogFork: Component = () => {
|
|||||||
|
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
const restored = extractPromptFromParts(
|
||||||
|
normalizeSessionMessages(sessionID, data.session.message.list(sessionID)).parts.get(item.id) ?? [],
|
||||||
|
{
|
||||||
|
directory: location().directory,
|
||||||
|
attachmentName: language.t("common.attachment"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const dir = base64Encode(location().directory)
|
||||||
|
|
||||||
const parts = sync().data.part[item.id] ?? []
|
serverSDK.api.session
|
||||||
const restored = extractPromptFromParts(parts, {
|
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||||
directory: sdk().directory,
|
|
||||||
attachmentName: language.t("common.attachment"),
|
|
||||||
})
|
|
||||||
const dir = base64Encode(sdk().directory)
|
|
||||||
|
|
||||||
sdk()
|
|
||||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
|
data.session.remember(forked)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.id}`)
|
navigate(sessionHref(server.key, forked.id))
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, createMemo, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useData } from "@/context/server"
|
||||||
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
@@ -14,12 +15,13 @@ const statusLabels = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
|
const sdk = useWorkspaceLocation()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync().data.mcp ?? {})
|
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
||||||
.map(([name, status]) => ({ name, status: status.status }))
|
.map((server) => ({ name: server.name, status: server.status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync().data.mcp[i.name]
|
const mcpStatus = () =>
|
||||||
|
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { ExternalLink } from "./external-link"
|
import { ExternalLink } from "./external-link"
|
||||||
|
|
||||||
type SkillItem = {
|
type SkillItem = {
|
||||||
@@ -15,7 +14,6 @@ type SkillItem = {
|
|||||||
location: string
|
location: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
|
||||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||||
|
|
||||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||||
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const ExtensionRow: Component<{
|
const ExtensionRow: Component<{
|
||||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
icon: "mcp" | "cube" | "post-skill"
|
||||||
name: string
|
name: string
|
||||||
status?: string
|
|
||||||
children?: unknown
|
children?: unknown
|
||||||
}> = (props) => (
|
}> = (props) => (
|
||||||
<div class="project-settings-extension-row">
|
<div class="project-settings-extension-row">
|
||||||
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
|
|||||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Show when={props.status}>
|
|
||||||
{(status) => (
|
|
||||||
<span class="project-settings-extension-row-status">
|
|
||||||
<span class="project-settings-extension-row-status-dot" />
|
|
||||||
{status()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
{props.children as any}
|
{props.children as any}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -75,56 +64,55 @@ const SharedSection: Component<{
|
|||||||
export const ProjectSettingsExtensions: Component = () => {
|
export const ProjectSettingsExtensions: Component = () => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const directorySDK = useSDK()
|
const directorySDK = useWorkspaceLocation()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const sync = useSync()
|
const toggleMcp = useMcpToggle(() => directorySDK().directory)
|
||||||
const toggleMcp = useMcpToggle()
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
|
const ref = { directory: directorySDK().directory }
|
||||||
|
void Promise.all([
|
||||||
|
data.location.mcp.server.sync(),
|
||||||
|
data.location.skill.sync(),
|
||||||
|
data.location.mcp.server.sync(ref),
|
||||||
|
data.location.skill.sync(ref),
|
||||||
|
]).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
const [serverMcp] = createResource(
|
|
||||||
serverSDK,
|
|
||||||
(sdk) =>
|
|
||||||
sdk.api.mcp
|
|
||||||
.list()
|
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
|
||||||
.catch(() => ({})),
|
|
||||||
{ initialValue: {} },
|
|
||||||
)
|
|
||||||
const globalMcpNames = createMemo(() =>
|
const globalMcpNames = createMemo(() =>
|
||||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
|
||||||
)
|
)
|
||||||
const projectMcpNames = createMemo(() => {
|
const projectMcpNames = createMemo(() => {
|
||||||
const shared = new Set(globalMcpNames())
|
const shared = new Set(globalMcpNames())
|
||||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
||||||
if (configured.length > 0) return configured.sort()
|
.map((server) => server.name)
|
||||||
return Object.keys(sync().data.mcp ?? {})
|
|
||||||
.filter((name) => !shared.has(name))
|
.filter((name) => !shared.has(name))
|
||||||
.sort()
|
.sort()
|
||||||
})
|
})
|
||||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
const mcpEnabled = (name: string) =>
|
||||||
|
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
|
||||||
|
.status === "connected"
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
const [globalPluginList] = createResource(
|
||||||
|
() => serverSDK.connection.status() === "connected",
|
||||||
|
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||||
|
)
|
||||||
|
const [projectPluginList] = createResource(
|
||||||
|
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||||
|
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||||
|
)
|
||||||
|
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||||
const projectPlugins = createMemo(() => {
|
const projectPlugins = createMemo(() => {
|
||||||
const shared = new Set(globalPlugins())
|
const shared = new Set(globalPlugins())
|
||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||||
})
|
})
|
||||||
|
|
||||||
const [serverSkills] = createResource(
|
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||||
serverSDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const [directorySkills] = createResource(
|
|
||||||
directorySDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill
|
|
||||||
.list({ location: { directory: sdk.directory } })
|
|
||||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const projectSkills = createMemo(() => {
|
const projectSkills = createMemo(() => {
|
||||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
const shared = new Set(serverSkills().map(skillKey))
|
||||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
||||||
|
(item) => !shared.has(skillKey(item)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpRows = (items: string[]) => (
|
const mcpRows = (items: string[]) => (
|
||||||
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
|
|
||||||
<TabsV2.Content value="mcps">
|
<TabsV2.Content value="mcps">
|
||||||
@@ -205,33 +193,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
<Show when={projectSkills().length > 0}>
|
<Show when={projectSkills().length > 0}>
|
||||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||||
</Show>
|
</Show>
|
||||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|
||||||
<TabsV2.Content value="lsps">
|
|
||||||
<div class="project-settings-extension-section">
|
|
||||||
<div class="project-settings-extension-section-header">
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
|
||||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
|
||||||
</div>
|
|
||||||
<Show when={sync().data.lsp.length > 0}>
|
|
||||||
<ExtensionCard>
|
|
||||||
<For each={sync().data.lsp}>
|
|
||||||
{(item) => (
|
|
||||||
<ExtensionRow
|
|
||||||
icon="code"
|
|
||||||
name={item.name || item.id}
|
|
||||||
status={
|
|
||||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</ExtensionCard>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</TabsV2.Content>
|
|
||||||
</TabsV2>
|
</TabsV2>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ import { useLayout } from "@/context/layout"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useWorkspaceLocation } from "@/context/location"
|
||||||
import { useSync } from "@/context/sync"
|
import { useData } from "@/context/server"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||||
@@ -40,7 +40,7 @@ export type PromptInputV2ComposerProps = {
|
|||||||
accentSubmit?: boolean
|
accentSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
|
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class">
|
||||||
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
|
||||||
readonly model: PromptInputProps["controls"]["model"]
|
readonly model: PromptInputProps["controls"]["model"]
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useWorkspaceLocation()
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
return [...result, path]
|
return [...result, path]
|
||||||
}, [])
|
}, [])
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||||
const attachments = createMemo(() =>
|
const attachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -197,37 +197,39 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
||||||
return permission.isAutoAccepting(id, sdk().directory)
|
return permission.isAutoAccepting(id, sdk().directory)
|
||||||
})
|
})
|
||||||
const submission = createPromptSubmit({
|
const submission =
|
||||||
prompt,
|
props.submission ??
|
||||||
info,
|
createPromptSubmit({
|
||||||
imageAttachments: attachments,
|
prompt,
|
||||||
commentCount,
|
info,
|
||||||
autoAccept: accepting,
|
imageAttachments: attachments,
|
||||||
mode,
|
commentCount,
|
||||||
working,
|
autoAccept: accepting,
|
||||||
editor: () => editor,
|
mode,
|
||||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
working,
|
||||||
promptLength,
|
editor: () => editor,
|
||||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||||
resetHistoryNavigation: () => controller.resetHistory(),
|
promptLength,
|
||||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||||
setPopover: (popover) => {
|
resetHistoryNavigation: () => controller.resetHistory(),
|
||||||
if (!popover) controller.dispatch({ type: "popover.close" })
|
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||||
},
|
setPopover: (popover) => {
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
if (!popover) controller.dispatch({ type: "popover.close" })
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
},
|
||||||
shouldQueue: props.shouldQueue,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onQueue: props.onQueue,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
onAbort: props.onAbort,
|
shouldQueue: props.shouldQueue,
|
||||||
onSubmit: props.onSubmit,
|
onQueue: props.onQueue,
|
||||||
model: props.controls.model.selection,
|
onAbort: props.onAbort,
|
||||||
})
|
onSubmit: props.onSubmit,
|
||||||
|
model: props.controls.model.selection,
|
||||||
|
})
|
||||||
|
|
||||||
const referenceDescription = (reference: ReferenceInfo) =>
|
const referenceDescription = (reference: ReferenceInfo) =>
|
||||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||||
const references = createMemo(() =>
|
const references = createMemo(() =>
|
||||||
sync()
|
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||||
.data.reference.filter((reference) => !reference.hidden)
|
.filter((reference) => !reference.hidden)
|
||||||
.map((reference) => ({
|
.map((reference) => ({
|
||||||
id: `reference:${reference.name}`,
|
id: `reference:${reference.name}`,
|
||||||
kind: "reference" as const,
|
kind: "reference" as const,
|
||||||
@@ -246,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
||||||
id: `resource:${resource.server}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
@@ -292,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
const slashCommands = createMemo(() => [
|
const slashCommands = createMemo(() => [
|
||||||
...sync().data.command.map((item) => ({
|
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||||
id: `custom.${item.name}`,
|
id: `custom.${item.name}`,
|
||||||
trigger: item.name,
|
trigger: item.name,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
@@ -352,7 +354,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||||
openContext(key) {
|
openContext(key) {
|
||||||
const item = controller.contextItem(key)
|
const item = controller.contextItem(key)
|
||||||
if (item) openComment(item, props, sync, layout, files, comments)
|
if (item) openComment(item, props, layout, files, comments)
|
||||||
},
|
},
|
||||||
onEditor(element) {
|
onEditor(element) {
|
||||||
editor = element as HTMLDivElement
|
editor = element as HTMLDivElement
|
||||||
@@ -555,7 +557,6 @@ function PromptInputV2ModelControl(props: {
|
|||||||
function openComment(
|
function openComment(
|
||||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||||
props: PromptInputV2ControllerProps,
|
props: PromptInputV2ControllerProps,
|
||||||
sync: ReturnType<typeof useSync>,
|
|
||||||
layout: ReturnType<typeof useLayout>,
|
layout: ReturnType<typeof useLayout>,
|
||||||
files: ReturnType<typeof useFile>,
|
files: ReturnType<typeof useFile>,
|
||||||
comments: ReturnType<typeof useComments>,
|
comments: ReturnType<typeof useComments>,
|
||||||
@@ -573,9 +574,7 @@ function openComment(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
const review = item.commentOrigin === "review"
|
||||||
const review =
|
|
||||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
|
||||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||||
if (review) {
|
if (review) {
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { Todo } from "@/types"
|
|
||||||
import { createPromptState } from "@/context/prompt"
|
import { createPromptState } from "@/context/prompt"
|
||||||
import { SessionComposerRegion, createSessionComposerRegionController } from "@/pages/session/composer"
|
|
||||||
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
import { createPromptInputHistory, PromptInput } from "./prompt-input"
|
||||||
|
|
||||||
function createPromptInputStoryRuntime() {
|
function createPromptInputStoryRuntime() {
|
||||||
@@ -111,93 +109,6 @@ function PromptInputExample() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const todos: Todo[] = [
|
|
||||||
{ id: "todo-1", content: "Inspect the session composer animation", status: "completed" },
|
|
||||||
{ id: "todo-2", content: "Keep the dock settled on initial render", status: "in_progress" },
|
|
||||||
{ id: "todo-3", content: "Verify session navigation behavior", status: "pending" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function PromptInputWithOpenDock() {
|
|
||||||
const input = createPromptInputStoryRuntime()
|
|
||||||
const [controls, setControls] = createStore({
|
|
||||||
agent: "build",
|
|
||||||
activeTab: undefined as string | undefined,
|
|
||||||
todoCollapsed: false,
|
|
||||||
})
|
|
||||||
const inputControls = {
|
|
||||||
agents: {
|
|
||||||
available: [],
|
|
||||||
options: ["build"],
|
|
||||||
get current() {
|
|
||||||
return controls.agent
|
|
||||||
},
|
|
||||||
loading: false,
|
|
||||||
visible: true,
|
|
||||||
select: (agent?: string) => setControls("agent", agent ?? "build"),
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
selection: {
|
|
||||||
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
|
|
||||||
variant: { list: () => [], current: () => undefined, set: () => {} },
|
|
||||||
},
|
|
||||||
paid: true,
|
|
||||||
loading: false,
|
|
||||||
},
|
|
||||||
session: {
|
|
||||||
id: "story-session",
|
|
||||||
tabs: {
|
|
||||||
active: () => controls.activeTab,
|
|
||||||
all: () => [],
|
|
||||||
open: () => {},
|
|
||||||
setActive: (tab: string) => setControls("activeTab", tab),
|
|
||||||
},
|
|
||||||
reviewPanel: { opened: () => false, open: () => {} },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
const state = {
|
|
||||||
blocked: () => false,
|
|
||||||
questionRequest: () => undefined,
|
|
||||||
permissionRequest: () => undefined,
|
|
||||||
permissionResponding: () => false,
|
|
||||||
decide: () => {},
|
|
||||||
todos: () => todos,
|
|
||||||
dock: () => true,
|
|
||||||
closing: () => false,
|
|
||||||
opening: () => false,
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<SessionComposerRegion
|
|
||||||
controller={createSessionComposerRegionController({
|
|
||||||
state,
|
|
||||||
sessionKey: () => "story-session",
|
|
||||||
sessionID: () => "story-session",
|
|
||||||
prompt: input.state,
|
|
||||||
ready: () => true,
|
|
||||||
centered: () => false,
|
|
||||||
todo: {
|
|
||||||
collapsed: () => controls.todoCollapsed,
|
|
||||||
onToggle: () => setControls("todoCollapsed", (collapsed) => !collapsed),
|
|
||||||
},
|
|
||||||
followup: () => undefined,
|
|
||||||
revert: () => undefined,
|
|
||||||
onResponseSubmit: () => {},
|
|
||||||
openParent: () => {},
|
|
||||||
setPromptRef: () => {},
|
|
||||||
setDockRef: () => {},
|
|
||||||
})}
|
|
||||||
promptInput={
|
|
||||||
<PromptInput
|
|
||||||
controls={inputControls}
|
|
||||||
{...input}
|
|
||||||
ref={() => {}}
|
|
||||||
newSessionWorktree=""
|
|
||||||
onNewSessionWorktreeReset={() => {}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "App/PromptInput",
|
title: "App/PromptInput",
|
||||||
id: "app-prompt-input",
|
id: "app-prompt-input",
|
||||||
@@ -212,12 +123,3 @@ export const Basic = {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DockAlreadyOpen = {
|
|
||||||
render: () => (
|
|
||||||
<div class="pt-10">
|
|
||||||
<h1 class="mb-4">Prompt Input with open Todo dock</h1>
|
|
||||||
<PromptInputWithOpenDock />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +0,0 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
|
||||||
import { Dynamic } from "solid-js/web"
|
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
||||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
|
||||||
import type { ContextItem } from "@/context/prompt"
|
|
||||||
|
|
||||||
type PromptContextItem = ContextItem & { key: string }
|
|
||||||
|
|
||||||
type ContextItemsProps = {
|
|
||||||
items: PromptContextItem[]
|
|
||||||
active: (item: PromptContextItem) => boolean
|
|
||||||
openComment: (item: PromptContextItem) => void
|
|
||||||
remove: (item: PromptContextItem) => void
|
|
||||||
newLayoutDesigns: boolean
|
|
||||||
t: (key: string) => string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<Show when={props.items.length > 0}>
|
|
||||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
|
||||||
<For each={props.items}>
|
|
||||||
{(item) => {
|
|
||||||
const directory = getDirectory(item.path)
|
|
||||||
const filename = getFilename(item.path)
|
|
||||||
const label = getFilenameTruncated(item.path, 14)
|
|
||||||
const selected = props.active(item)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dynamic
|
|
||||||
component={props.newLayoutDesigns ? TooltipV2 : Tooltip}
|
|
||||||
value={
|
|
||||||
<span class="flex max-w-[300px]">
|
|
||||||
<span
|
|
||||||
classList={{
|
|
||||||
"truncate-start [unicode-bidi:plaintext] min-w-0": true,
|
|
||||||
"text-v2-text-text-muted": props.newLayoutDesigns,
|
|
||||||
"text-text-invert-base": !props.newLayoutDesigns,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{directory}
|
|
||||||
</span>
|
|
||||||
<span class="shrink-0">{filename}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
placement="top"
|
|
||||||
openDelay={800}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
classList={{
|
|
||||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
|
|
||||||
"hover:bg-surface-interactive-weak": !!item.commentID && !selected,
|
|
||||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
|
|
||||||
"bg-background-stronger": !selected,
|
|
||||||
}}
|
|
||||||
onClick={() => props.openComment(item)}
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-1.5">
|
|
||||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
|
||||||
<div class="flex items-center text-[12px] min-w-0 font-medium leading-5">
|
|
||||||
<span class="text-text-strong whitespace-nowrap">{label}</span>
|
|
||||||
<Show when={item.selection}>
|
|
||||||
{(sel) => (
|
|
||||||
<span class="text-text-weak whitespace-nowrap shrink-0">
|
|
||||||
{sel().startLine === sel().endLine
|
|
||||||
? `:${sel().startLine}`
|
|
||||||
: `:${sel().startLine}-${sel().endLine}`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
icon="close-small"
|
|
||||||
variant="ghost"
|
|
||||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
props.remove(item)
|
|
||||||
}}
|
|
||||||
aria-label={props.t("prompt.context.removeFile")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Show when={item.comment}>
|
|
||||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Dynamic>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { Component, Show } from "solid-js"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
|
|
||||||
type PromptDragOverlayProps = {
|
|
||||||
type: "image" | "@mention" | null
|
|
||||||
label: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const kindToIcon = {
|
|
||||||
image: "photo",
|
|
||||||
"@mention": "link",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<Show when={props.type !== null}>
|
|
||||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
|
||||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
|
||||||
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
|
|
||||||
<span class="text-14-regular">{props.label}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -16,6 +16,21 @@ export type PromptInputHistory = {
|
|||||||
|
|
||||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||||
|
|
||||||
|
export function upgradeHistoryState(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
|
||||||
|
const entries = value.entries
|
||||||
|
if (!Array.isArray(entries)) return value
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||||
|
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
|
||||||
|
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||||
|
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||||
|
return [entry as PromptHistoryStoredEntry]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createPromptInputHistoryStore(
|
function createPromptInputHistoryStore(
|
||||||
normal: Store<PromptHistoryState>,
|
normal: Store<PromptHistoryState>,
|
||||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||||
@@ -42,11 +57,11 @@ export function createPromptInputHistory(): PromptInputHistory {
|
|||||||
|
|
||||||
export function createPersistedPromptInputHistory() {
|
export function createPersistedPromptInputHistory() {
|
||||||
const [normal, setNormal, normalInit] = persisted(
|
const [normal, setNormal, normalInit] = persisted(
|
||||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
)
|
)
|
||||||
const [shell, setShell, shellInit] = persisted(
|
const [shell, setShell, shellInit] = persisted(
|
||||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||||
createStore<PromptHistoryState>({ entries: [] }),
|
createStore<PromptHistoryState>({ entries: [] }),
|
||||||
)
|
)
|
||||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ import type { Prompt } from "@/context/prompt"
|
|||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
clonePromptParts,
|
clonePromptParts,
|
||||||
normalizePromptHistoryEntry,
|
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
prependHistoryEntry,
|
prependHistoryEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
type PromptHistoryComment,
|
type PromptHistoryComment,
|
||||||
} from "./history"
|
} from "./history"
|
||||||
|
import { upgradeHistoryState } from "./history-store"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||||
|
const entry = (value: string) => ({ prompt: text(value), comments: [] })
|
||||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||||
id,
|
id,
|
||||||
path: "src/a.ts",
|
path: "src/a.ts",
|
||||||
@@ -42,7 +43,7 @@ describe("prompt-input history", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||||
const entries = [text("third"), text("second"), text("first")]
|
const entries = [entry("third"), entry("second"), entry("first")]
|
||||||
const up = navigatePromptHistory({
|
const up = navigatePromptHistory({
|
||||||
direction: "up",
|
direction: "up",
|
||||||
entries,
|
entries,
|
||||||
@@ -95,10 +96,10 @@ describe("prompt-input history", () => {
|
|||||||
expect(up.entry.comments).toEqual([comment("c1")])
|
expect(up.entry.comments).toEqual([comment("c1")])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||||
const entry = normalizePromptHistoryEntry(text("legacy"))
|
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||||
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
entries: [{ prompt: text("stored"), comments: [] }],
|
||||||
expect(entry.comments).toEqual([])
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("helpers clone prompt and count text content length", () => {
|
test("helpers clone prompt and count text content length", () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type PromptHistoryEntry = {
|
|||||||
comments: PromptHistoryComment[]
|
comments: PromptHistoryComment[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||||
|
|
||||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
const position = Math.max(0, Math.min(cursor, text.length))
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
@@ -60,12 +60,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||||
if (Array.isArray(entry)) {
|
|
||||||
return {
|
|
||||||
prompt: clonePromptParts(entry),
|
|
||||||
comments: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
prompt: clonePromptParts(entry.prompt),
|
prompt: clonePromptParts(entry.prompt),
|
||||||
comments: clonePromptHistoryComments(entry.comments),
|
comments: clonePromptHistoryComments(entry.comments),
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
@keyframes prompt-attachments-fade-left {
|
|
||||||
from {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes prompt-attachments-fade-right {
|
|
||||||
from {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="prompt-attachments"] {
|
|
||||||
timeline-scope: --prompt-attachments-scroll;
|
|
||||||
|
|
||||||
[data-slot^="prompt-attachments-fade-"] {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (animation-timeline: --prompt-attachments-scroll) and (timeline-scope: --prompt-attachments-scroll) {
|
|
||||||
[data-slot="prompt-attachments-scroll"] {
|
|
||||||
scroll-timeline: --prompt-attachments-scroll x;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="prompt-attachments-fade-left"] {
|
|
||||||
animation: prompt-attachments-fade-left linear both;
|
|
||||||
animation-timeline: --prompt-attachments-scroll;
|
|
||||||
animation-range: 0 0.1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-slot="prompt-attachments-fade-right"] {
|
|
||||||
animation: prompt-attachments-fade-right linear both;
|
|
||||||
animation-timeline: --prompt-attachments-scroll;
|
|
||||||
animation-range: calc(100% - 1.1px) calc(100% - 1px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
|
||||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
|
||||||
import { AttachmentCardV2 } from "@opencode-ai/session-ui/v2/attachment-card-v2"
|
|
||||||
import { CommentCardV2 } from "@opencode-ai/session-ui/v2/comment-card-v2"
|
|
||||||
import { typeLabel } from "@opencode-ai/session-ui/message-file"
|
|
||||||
import type { ContextItem, ImageAttachmentPart } from "@/context/prompt"
|
|
||||||
import "./image-attachments.css"
|
|
||||||
|
|
||||||
type PromptCommentItem = ContextItem & { key: string }
|
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
|
||||||
attachments: ImageAttachmentPart[]
|
|
||||||
onOpen: (attachment: ImageAttachmentPart) => void
|
|
||||||
onRemove: (id: string) => void
|
|
||||||
removeLabel: string
|
|
||||||
fileLabel: string
|
|
||||||
newLayoutDesigns: boolean
|
|
||||||
comments?: PromptCommentItem[]
|
|
||||||
commentActive?: (item: PromptCommentItem) => boolean
|
|
||||||
onOpenComment?: (item: PromptCommentItem) => void
|
|
||||||
onRemoveComment?: (item: PromptCommentItem) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
|
|
||||||
const imageClass =
|
|
||||||
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
|
||||||
const imageClassV2 = "w-[58px] h-[46px] rounded-[6px] object-cover"
|
|
||||||
// inset box-shadows do not paint over <img> content, so the hairline is a separate overlay
|
|
||||||
const imageHairlineClassV2 =
|
|
||||||
"absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none"
|
|
||||||
const removeClass =
|
|
||||||
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
|
||||||
const removeClassV2 =
|
|
||||||
"absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
|
|
||||||
|
|
||||||
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<Show when={props.attachments.length > 0 || (props.newLayoutDesigns && (props.comments?.length ?? 0) > 0)}>
|
|
||||||
<div data-slot="prompt-attachments" classList={{ relative: props.newLayoutDesigns }}>
|
|
||||||
<div
|
|
||||||
data-slot="prompt-attachments-scroll"
|
|
||||||
classList={{
|
|
||||||
"flex gap-2": true,
|
|
||||||
"flex-nowrap overflow-x-auto no-scrollbar px-2 pt-2 pb-1": props.newLayoutDesigns,
|
|
||||||
"flex-wrap px-3 pt-3": !props.newLayoutDesigns,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={props.newLayoutDesigns}>
|
|
||||||
<For each={props.comments ?? []}>
|
|
||||||
{(item) => (
|
|
||||||
<div class="relative group shrink-0">
|
|
||||||
<TooltipV2
|
|
||||||
value={item.comment}
|
|
||||||
placement="top"
|
|
||||||
openDelay={800}
|
|
||||||
contentClass="max-w-[300px] break-words"
|
|
||||||
>
|
|
||||||
<CommentCardV2
|
|
||||||
comment={item.comment ?? ""}
|
|
||||||
path={item.path}
|
|
||||||
selection={item.selection}
|
|
||||||
active={props.commentActive?.(item)}
|
|
||||||
onClick={() => props.onOpenComment?.(item)}
|
|
||||||
/>
|
|
||||||
</TooltipV2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => props.onRemoveComment?.(item)}
|
|
||||||
class={removeClassV2}
|
|
||||||
aria-label={props.removeLabel}
|
|
||||||
>
|
|
||||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
<For each={props.attachments}>
|
|
||||||
{(attachment) => {
|
|
||||||
const image = attachment.mime.startsWith("image/")
|
|
||||||
const media = () => (
|
|
||||||
<Show
|
|
||||||
when={image}
|
|
||||||
fallback={
|
|
||||||
<Show
|
|
||||||
when={props.newLayoutDesigns}
|
|
||||||
fallback={
|
|
||||||
<div class={fallbackClass}>
|
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AttachmentCardV2 title={attachment.filename}>
|
|
||||||
{typeLabel(attachment.filename, attachment.mime, props.fileLabel)}
|
|
||||||
</AttachmentCardV2>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={attachment.blob.url}
|
|
||||||
alt={attachment.filename}
|
|
||||||
class={props.newLayoutDesigns ? imageClassV2 : imageClass}
|
|
||||||
onClick={() => props.onOpen(attachment)}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
const name = () => (
|
|
||||||
<div class={nameClass}>
|
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const remove = () => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
|
||||||
class={props.newLayoutDesigns ? removeClassV2 : removeClass}
|
|
||||||
aria-label={props.removeLabel}
|
|
||||||
>
|
|
||||||
<Show when={props.newLayoutDesigns} fallback={<Icon name="close" class="size-3 text-text-weak" />}>
|
|
||||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
|
||||||
</Show>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
// v2 keeps the remove button outside the tooltip trigger so hovering it dismisses the tooltip
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={props.newLayoutDesigns}
|
|
||||||
fallback={
|
|
||||||
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
|
||||||
<div class="relative group">
|
|
||||||
{media()}
|
|
||||||
{name()}
|
|
||||||
{remove()}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="relative group shrink-0">
|
|
||||||
<TooltipV2 value={attachment.filename} placement="top" contentClass="break-all">
|
|
||||||
{media()}
|
|
||||||
<Show when={image}>
|
|
||||||
<div class={imageHairlineClassV2} />
|
|
||||||
</Show>
|
|
||||||
</TooltipV2>
|
|
||||||
{remove()}
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<Show when={props.newLayoutDesigns}>
|
|
||||||
<div
|
|
||||||
data-slot="prompt-attachments-fade-left"
|
|
||||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
data-slot="prompt-attachments-fade-right"
|
|
||||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user