mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 23:46:19 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03578a12cd | ||
|
|
cd6cd7d4b9 | ||
|
|
d1749770c7 | ||
|
|
9ee97d87a2 | ||
|
|
59e5953443 | ||
|
|
4a0f273fa7 | ||
|
|
36a38058fb | ||
|
|
ccb5162714 | ||
|
|
c2f87853c6 | ||
|
|
ed0e7acecd | ||
|
|
de2cacdac8 | ||
|
|
6bffe7932e | ||
|
|
ba281e7f91 | ||
|
|
f17b15eaa5 | ||
|
|
f6ae986549 | ||
|
|
fb72e25c06 | ||
|
|
184890e429 | ||
|
|
a7523ac4b7 | ||
|
|
80c4a6b7c8 | ||
|
|
592daf29f4 | ||
|
|
b1629294ec | ||
|
|
8eb2ebd96c | ||
|
|
2eff7da1b6 | ||
|
|
da20865bb9 | ||
|
|
6e4de805bb | ||
|
|
50ae4bd4db | ||
|
|
89a8f1a3db | ||
|
|
81b92c043f | ||
|
|
cabb5d035b | ||
|
|
2bddf58716 | ||
|
|
8f0f4be03c | ||
|
|
b78d10cd71 | ||
|
|
cb11287ae6 | ||
|
|
9daf46b327 | ||
|
|
bd55d4895f | ||
|
|
7c59e7bfee | ||
|
|
32a67d2d0a | ||
|
|
a1d2085af3 | ||
|
|
d318cf4125 | ||
|
|
5ae8a3c1d3 | ||
|
|
209346a0fe | ||
|
|
3ca0ac718b | ||
|
|
48b295aef4 | ||
|
|
19f8610c0a | ||
|
|
754b144263 | ||
|
|
394a4fc64b |
No files matched your search
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -192,7 +192,6 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
@@ -205,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
@@ -369,13 +368,11 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.18.0",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gitlab-ai-provider": "6.19.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -409,7 +406,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -454,7 +451,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -491,7 +488,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -507,7 +504,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -552,7 +549,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -571,7 +568,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -584,7 +581,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -598,7 +595,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -613,7 +610,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -621,9 +618,7 @@
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
@@ -652,7 +647,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -682,7 +677,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -697,7 +692,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -721,7 +716,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -742,7 +737,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -751,7 +746,6 @@
|
||||
"@opencode/schema": "workspace:*",
|
||||
"@opencode/simulation": "workspace:*",
|
||||
"@opencode/util": "workspace:*",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -764,7 +758,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -799,7 +793,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -819,7 +813,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -853,7 +847,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -872,7 +866,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -918,7 +912,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -932,7 +926,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -966,7 +960,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -975,6 +969,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.47",
|
||||
"marked": "catalog:",
|
||||
"marked-katex-extension": "5.1.13",
|
||||
"marked-shiki": "catalog:",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
@@ -1001,7 +996,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1039,7 +1034,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1080,7 +1075,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -4153,7 +4148,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.18.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-dXTXkNt1SFCL7jGlqazHL6iUJEug21qq0tx2s+Tui7jWNUqIAHEzY9WY1+PnZlhxvB56jPPBcFAT0attpjnopg=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.19.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-ym+p6PxKvCKfBE3VKYTiacJwsmgq/DrOuGctpBnbIBekdnZT31RM6yTTNdzVS83K3+Ym4M+qNY34HEcTVM6EEQ=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -4299,7 +4294,7 @@
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
@@ -4613,6 +4608,8 @@
|
||||
|
||||
"marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="],
|
||||
|
||||
"marked-katex-extension": ["marked-katex-extension@5.1.13", "", { "peerDependencies": { "katex": ">=0.16 <0.19", "marked": ">=4 <19" } }, "sha512-+6gVygGg5at9g+YsuIQZk7XcAc6Pg1P0grEJKEWvF7DAwceZ7ZIvU0fhQe6auUqSbnPmSjJxZuICdSCdbGaFRw=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
"marky": ["marky@1.3.0", "", {}, "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ=="],
|
||||
@@ -6517,8 +6514,6 @@
|
||||
|
||||
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-4AqU8dPEwo0ukoyEX1SuDVzsXQ7G/fIPnxoL400zH4M=",
|
||||
"aarch64-linux": "sha256-p75sJYtR8a4oVKb1+Dgp8Kl+1onSmUw5NRv95bbV1+E=",
|
||||
"aarch64-darwin": "sha256-Psyvbw/lGulysQlhK/MqcHVIwx2aCDTr+Pfr+gRWsG4="
|
||||
"x86_64-linux": "sha256-mrsVbX15SgLsQWBknYKmHSBpWhE6BnyUrYzxIWL35js=",
|
||||
"aarch64-linux": "sha256-uskMkbG9jeGoal1mHV4rpkkvBb3TDBvQcDdQT/bP9y4=",
|
||||
"aarch64-darwin": "sha256-/8nVBK6lBRO34Id/jKOUeEaEHHYJhh8cFESWfhzzln0="
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -227,17 +227,17 @@ story("keeps the comment editor and its actions inside the page", async ({ page
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Preview your app, read docs, or browse the web.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
@@ -245,9 +245,9 @@ story("shows the empty state over a blank native page and restores navigation",
|
||||
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Loading page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
|
||||
await root.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
@@ -255,12 +255,12 @@ story("keeps the submitted URL visible until the browser reports navigation", as
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
|
||||
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
@@ -274,7 +274,7 @@ story("keeps the submitted URL visible until the browser reports navigation", as
|
||||
story("restores the current URL each time the same submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
|
||||
for (const submission of [1, 2]) {
|
||||
await story.step(`blocked submission ${submission}`, async () => {
|
||||
@@ -296,7 +296,7 @@ story("shows a themed failure state for only the failed tab and allows retry", a
|
||||
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
await expect(root.getByRole("combobox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"https://alpha.example/",
|
||||
)
|
||||
|
||||
@@ -317,7 +317,7 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
@@ -326,14 +326,14 @@ story("returns a failed tab to the empty state when an empty URL is submitted",
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("selects the full URL when the address field gains focus", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
@@ -352,10 +352,55 @@ story("selects the full URL when the address field gains focus", async ({ page }
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
story("draws only the address at rest when focus leaves the window with the field selected", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
// Chromium blurs the field when the window or the native page takes focus, and the field keeps its full selection.
|
||||
await address.evaluate((input: HTMLInputElement) => {
|
||||
input.dispatchEvent(new FocusEvent("blur"))
|
||||
input.select()
|
||||
})
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
await page.mouse.move(0, 0)
|
||||
|
||||
// Computed ::selection styles do not report the default highlight, so compare the input's own paint with the
|
||||
// selection collapsed. The drawn address is hidden: a selection layer can change how its text is antialiased.
|
||||
await page.addStyleTag({ content: '[data-slot="browser-address-display"] { visibility: hidden }' })
|
||||
const field = root.locator('[data-component="browser-address"]')
|
||||
const selected = (await field.screenshot()).toString("base64")
|
||||
await address.evaluate((input: HTMLInputElement) => input.setSelectionRange(0, 0))
|
||||
const collapsed = (await field.screenshot()).toString("base64")
|
||||
|
||||
const changed = await page.evaluate(
|
||||
async (shots) => {
|
||||
const pixels = await Promise.all(
|
||||
shots.map(async (data) => {
|
||||
const image = new Image()
|
||||
image.src = `data:image/png;base64,${data}`
|
||||
await image.decode()
|
||||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
canvas.getContext("2d")?.drawImage(image, 0, 0)
|
||||
|
||||
return canvas.getContext("2d")?.getImageData(0, 0, image.width, image.height).data ?? new Uint8ClampedArray()
|
||||
}),
|
||||
)
|
||||
|
||||
return Array.from({ length: (pixels[0]?.length ?? 0) / 4 }, (_, index) => index * 4).filter((offset) =>
|
||||
[0, 1, 2].some((channel) => pixels[0]?.[offset + channel] !== pixels[1]?.[offset + channel]),
|
||||
).length
|
||||
},
|
||||
[selected, collapsed],
|
||||
)
|
||||
|
||||
expect(changed).toBe(0)
|
||||
})
|
||||
|
||||
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
@@ -371,12 +416,74 @@ story("keeps the current page visible while a submitted URL loads", async ({ pag
|
||||
|
||||
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Open a page", { exact: true })).toBeHidden()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("suggests visited pages and opens the first one that completes the typed address", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Seed history", exact: true }).click()
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await address.pressSequentially("localhost")
|
||||
const options = page.getByRole("option")
|
||||
await expect(options).toHaveCount(1)
|
||||
await expect(options.first()).toHaveAttribute("aria-selected", "true")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("http://localhost:5173/settings")
|
||||
|
||||
// A search query also opens the first match; stepping past the list leaves the typed text, which searches.
|
||||
await address.click()
|
||||
await address.pressSequentially("color format")
|
||||
await expect(options).toHaveCount(1)
|
||||
await expect(options.first()).toHaveAttribute("aria-selected", "true")
|
||||
await address.press("ArrowDown")
|
||||
await expect(options.first()).toHaveAttribute("aria-selected", "false")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://www.google.com/search?q=color%20format")
|
||||
})
|
||||
|
||||
story("lists recent pages on a blank tab and opens one", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Seed history", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByText("Recent", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: /^Settings - Preview/ }).click()
|
||||
await expect(root.getByRole("combobox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"http://localhost:5173/settings",
|
||||
)
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("focuses the address field when the page uses the address shortcut", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Address shortcut", exact: true }).click()
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await expect(address).toBeFocused()
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
})
|
||||
|
||||
story("shows the page's cookies in the site information and clears them", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Site information", exact: true }).click()
|
||||
const site = page.getByRole("dialog")
|
||||
await expect(site).toContainText("3 cookies in use")
|
||||
await site.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(site).toContainText("0 cookies in use")
|
||||
})
|
||||
|
||||
story("zooms the page from the browser options and shows its zoom", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Browser options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: /^Zoom in/ }).click()
|
||||
await expect(root.getByText("Zoom: 110", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: /^Reset zoom/ })).toContainText("110%")
|
||||
await page.getByRole("menuitem", { name: /^Reset zoom/ }).click()
|
||||
await expect(root.getByText("Zoom: 100", { exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -95,7 +95,14 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
Math.abs((direction === "ltr" ? action.x - last.x - last.width : last.x - action.x - action.width) - 12),
|
||||
).toBeLessThan(1)
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("menuitemradio", { name: "high", exact: true })).toBeVisible()
|
||||
// Below the mobile breakpoint the variant picker opens in a bottom drawer instead of a menu.
|
||||
await expect(
|
||||
width < 768
|
||||
? page
|
||||
.getByRole("dialog", { name: "Choose model variant", exact: true })
|
||||
.getByRole("button", { name: "high", exact: true })
|
||||
: page.getByRole("menuitemradio", { name: "high", exact: true }),
|
||||
).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(variant).toBeFocused()
|
||||
await expect(submit).toBeInViewport()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const { width, direction } of [
|
||||
{ width: 390, direction: "ltr" },
|
||||
{ width: 390, direction: "rtl" },
|
||||
{ width: 1280, direction: "ltr" },
|
||||
] as const) {
|
||||
test(`submitting a prompt ${width < 768 ? "unfocuses" : "keeps focus on"} the composer at ${width}px in ${direction}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSubmitSession(page, width, direction)
|
||||
|
||||
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Please check focus after sending.")
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
const admitted = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
|
||||
)
|
||||
|
||||
await editor.press("Enter")
|
||||
expect((await admitted).ok()).toBe(true)
|
||||
await expect(editor).toBeEmpty()
|
||||
|
||||
if (width < 768) {
|
||||
await expect(editor).not.toBeFocused()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expect(editor).toBeFocused()
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("touch submission", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test("tapping Send unfocuses the composer", async ({ page }) => {
|
||||
await openSubmitSession(page, 390, "ltr")
|
||||
const editor = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Please dismiss the keyboard after sending.")
|
||||
await expect(editor).toBeFocused()
|
||||
|
||||
const admitted = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/session/${fixture.targetID}/prompt`,
|
||||
)
|
||||
|
||||
await page.getByRole("button", { name: "Send", exact: true }).tap()
|
||||
expect((await admitted).ok()).toBe(true)
|
||||
await expect(editor).toBeEmpty()
|
||||
await expect(editor).not.toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
async function openSubmitSession(page: Page, width: number, direction: "ltr" | "rtl") {
|
||||
await page.setViewportSize({ width, height: 844 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
|
||||
})
|
||||
await openWithDirection(page, sessionHref(fixture.targetID), direction)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const scheme of ["light", "dark"] as const) {
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile extensions reuse the session drawer in ${scheme} ${direction}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((scheme) => {
|
||||
localStorage.setItem("opencode-color-scheme", scheme)
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
|
||||
}, scheme)
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
await openWithDirection(page, sessionHref(fixture.targetID), direction)
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const more = tabs.getByRole("tab", { name: "More...", exact: true })
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
|
||||
await more.click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "More options", exact: true })
|
||||
.getByRole("button", { name: "Session details", exact: true })
|
||||
.click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
|
||||
|
||||
for (const [name, empty] of [
|
||||
["MCP", "No MCP servers configured"],
|
||||
["Plugins", "No plugins configured"],
|
||||
["Skills", "No skills configured"],
|
||||
["LSP", "No LSP servers configured"],
|
||||
]) {
|
||||
const trigger = details.getByRole("button", { name, exact: true })
|
||||
await trigger.click()
|
||||
const extension = page.getByRole("dialog", { name, exact: true })
|
||||
await expect(extension.getByText(empty, { exact: true })).toBeVisible()
|
||||
await expect(extension).toHaveAttribute("data-drawer-probe", "single")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(1)
|
||||
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
|
||||
await expect(extension.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
|
||||
await expect(extension.locator(".session-service-menu")).toHaveCount(0)
|
||||
await expect(extension).toBeInViewport({ ratio: 1 })
|
||||
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
|
||||
await details.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Skills", exact: true }).getByText("No skills configured", { exact: true }),
|
||||
).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("extensions.png") })
|
||||
await testInfo.attach("extensions", { path: testInfo.outputPath("extensions.png"), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await expect(
|
||||
page
|
||||
.getByRole("dialog", { name: "More options", exact: true })
|
||||
.getByRole("button", { name: "Files", exact: true }),
|
||||
).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("mobile extension drawers keep configured lists, retry, refresh, and MCP toggles working", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
const state = { fail: true, extra: false, enabled: true }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{
|
||||
id: "drawer-plugin",
|
||||
source: { type: "package", target: "drawer-plugin" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
...(state.extra
|
||||
? [
|
||||
{
|
||||
id: "extra-plugin",
|
||||
source: { type: "package", target: "extra-plugin" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "drawer-skill", name: "drawer-skill", path: "/skills/drawer/SKILL.md", content: "Drawer skill" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({ json: [{ type: "document", info: { lsp: { "drawer-lsp": { command: ["drawer-lsp"] } } } }] }),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname.startsWith("/api/mcp") || url.pathname.startsWith("/api/experimental/mcp"),
|
||||
(route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const path = new URL(route.request().url()).pathname
|
||||
|
||||
if (path.endsWith("/disconnect")) {
|
||||
state.enabled = false
|
||||
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
path === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `mcp-${String(index).padStart(2, "0")}`,
|
||||
status: { status: index === 0 && !state.enabled ? "disabled" : "connected" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await openWithDirection(page, sessionHref(fixture.targetID), "ltr")
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
|
||||
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "More options", exact: true })
|
||||
.getByRole("button", { name: "Session details", exact: true })
|
||||
.click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await details.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("drawer-plugin", { exact: true })).toBeVisible()
|
||||
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
state.extra = true
|
||||
await details.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(plugins.getByText("extra-plugin", { exact: true })).toBeVisible()
|
||||
await plugins.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
|
||||
for (const [name, item] of [
|
||||
["Skills", "drawer-skill"],
|
||||
["LSP", "drawer-lsp"],
|
||||
]) {
|
||||
await details.getByRole("button", { name, exact: true }).click()
|
||||
const extension = page.getByRole("dialog", { name, exact: true })
|
||||
await expect(extension.getByText(item, { exact: true })).toBeVisible()
|
||||
await extension.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
}
|
||||
|
||||
await details.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(mcp.getByRole("switch")).toHaveCount(30)
|
||||
await expect
|
||||
.poll(() =>
|
||||
mcp
|
||||
.locator('[data-slot="mobile-panel-content"]')
|
||||
.evaluate((element) => element.scrollHeight > element.clientHeight),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(mcp).toBeInViewport({ ratio: 1 })
|
||||
const disconnect = page.waitForRequest((request) => new URL(request.url()).pathname.endsWith("/disconnect"))
|
||||
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).focus()
|
||||
await mcp.getByRole("switch", { name: "mcp-00", exact: true }).press("Space")
|
||||
await disconnect
|
||||
await expect(mcp.getByRole("switch", { name: "mcp-00", exact: true })).not.toBeChecked()
|
||||
|
||||
const last = mcp
|
||||
.locator(".session-mcp-row")
|
||||
.filter({ has: page.getByRole("switch", { name: "mcp-29", exact: true }) })
|
||||
|
||||
await last.scrollIntoViewIfNeeded()
|
||||
await expect(last).toBeInViewport()
|
||||
await mcp.getByRole("switch", { name: "mcp-29", exact: true }).focus()
|
||||
await expect(mcp.getByRole("switch", { name: "mcp-29", exact: true })).toBeFocused()
|
||||
})
|
||||
@@ -1,10 +1,21 @@
|
||||
import { expect, test, type Request } from "@playwright/test"
|
||||
import { expect, test, type Page, type Request } from "@playwright/test"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { fileNode } from "../utils/workspace"
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } })
|
||||
|
||||
async function openFiles(page: Page) {
|
||||
await page
|
||||
.getByRole("tablist", { name: "Session view", exact: true })
|
||||
.getByRole("tab", { name: "More...", exact: true })
|
||||
.click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "More options", exact: true })
|
||||
.getByRole("button", { name: "Files", exact: true })
|
||||
.click()
|
||||
}
|
||||
|
||||
test("mobile files browse, comment, search, and close tabs", async ({ page }) => {
|
||||
await mockStressTimeline(page, {
|
||||
fileList: (path) => (path ? [] : ["first.ts", "second.ts"].map((name) => fileNode(fixture.directory, name))),
|
||||
@@ -14,7 +25,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
})
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
const navigation = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await openFiles(page)
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await files.getByRole("button", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
@@ -50,7 +61,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
.click()
|
||||
await expect(session).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await openFiles(page)
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
|
||||
await expect(filter).toBeHidden()
|
||||
@@ -63,7 +74,7 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
await openTabs.getByRole("tab", { name: "first.ts", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Session", exact: true }).click()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await openFiles(page)
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
await files
|
||||
.locator('[data-slot="tabs-v2-trigger-wrapper"]')
|
||||
@@ -239,7 +250,7 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
for (const file of ["added.ts", "modified.ts", "added.ts", "modified.ts"]) {
|
||||
await navigation.getByRole("tab", { name: "Changes", exact: true }).click()
|
||||
await review.locator(`[data-file="${file}"]`).getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(navigation.getByRole("tab", { name: "Files", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(navigation.getByRole("tab", { name: "More...", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(selected).toHaveText([file])
|
||||
await expect(files).toHaveAttribute("data-browsing", "false")
|
||||
|
||||
@@ -248,20 +259,23 @@ test("mobile changes summarize expanded diffs, stage comments, wrap by setting,
|
||||
}
|
||||
})
|
||||
|
||||
test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
test("summary drawer dismisses by backdrop, Escape, and drag", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
.getByRole("tablist", { name: "Session view", exact: true })
|
||||
.getByRole("tab", { name: "More...", exact: true })
|
||||
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag"] as const) {
|
||||
for (const dismissal of ["backdrop", "escape", "drag"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "More options", exact: true })
|
||||
.getByRole("button", { name: "Session details", exact: true })
|
||||
.click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
@@ -270,8 +284,6 @@ test("summary drawer dismisses by button, backdrop, Escape, and drag", async ({
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
|
||||
if (dismissal === "button") await drawer.getByRole("button", { name: "Close", exact: true }).click()
|
||||
|
||||
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
|
||||
|
||||
if (dismissal === "escape") await page.keyboard.press("Escape")
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { fixture, installStressSessionTabs } from "../utils/session-fixture"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
for (const paid of [true, false]) {
|
||||
test(`mobile model and variant pickers use drawers with ${paid ? "paid" : "free"} models in ${direction}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await openPickerSession(page, direction, paid)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await expect(variant).toHaveText("balanced")
|
||||
await variant.click()
|
||||
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
|
||||
await expect(variants.getByRole("button", { name: "balanced", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
)
|
||||
await expect(variants.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
|
||||
await expect(variants).toBeInViewport({ ratio: 1 })
|
||||
await expect(variants).not.toHaveAttribute("data-transitioning")
|
||||
await variants.screenshot({ path: testInfo.outputPath("variants.png") })
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(variants).toBeHidden()
|
||||
await expect(variant).toBeFocused()
|
||||
await variant.click()
|
||||
await variants.getByRole("button", { name: "high", exact: true }).click()
|
||||
await expect(variants).toBeHidden()
|
||||
await expect(variant).toHaveText("high")
|
||||
await expect(variant).toBeFocused()
|
||||
|
||||
const model = composer.locator('[data-action="composer-model"]')
|
||||
await expect(model).toHaveText("Mobile Model A")
|
||||
await expect(model).toHaveAttribute("data-control-type", "drawer")
|
||||
await model.click()
|
||||
const models = page.getByRole("dialog", { name: "Select model", exact: true })
|
||||
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
|
||||
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
|
||||
await expect(search).not.toBeFocused()
|
||||
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(models.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
|
||||
await expect(models).toBeInViewport({ ratio: 1 })
|
||||
await expect(models).not.toHaveAttribute("data-transitioning")
|
||||
await expect
|
||||
.poll(() =>
|
||||
models.evaluate((element) => {
|
||||
const drawer = element.getBoundingClientRect()
|
||||
const search = element.querySelector('[data-component="text-input-v2"]')!.getBoundingClientRect()
|
||||
const row = element.querySelector('[data-component="settings-row"]')!.getBoundingClientRect()
|
||||
const searchClip = element.querySelector('[data-slot="mobile-panel-content"]')!.getBoundingClientRect()
|
||||
const rowClip = element.querySelector(".settings-models")!.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
searchStart: Math.round(search.left - drawer.left),
|
||||
searchEnd: Math.round(drawer.right - search.right),
|
||||
rowStart: Math.round(row.left - drawer.left),
|
||||
rowEnd: Math.round(drawer.right - row.right),
|
||||
searchClipStart: Math.round(search.left - searchClip.left),
|
||||
searchClipEnd: Math.round(searchClip.right - search.right),
|
||||
rowClipStart: Math.round(row.left - rowClip.left),
|
||||
rowClipEnd: Math.round(rowClip.right - row.right),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.toEqual({
|
||||
searchStart: 12,
|
||||
searchEnd: 12,
|
||||
rowStart: 12,
|
||||
rowEnd: 12,
|
||||
searchClipStart: 12,
|
||||
searchClipEnd: 12,
|
||||
rowClipStart: 12,
|
||||
rowClipEnd: 12,
|
||||
})
|
||||
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-inline-start", "12px")
|
||||
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCSS("padding-block-start", "16px")
|
||||
await models.screenshot({ path: testInfo.outputPath("models.png") })
|
||||
await search.click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("no-matching-model")
|
||||
await expect(models.getByText("No model results", { exact: true })).toBeVisible()
|
||||
await search.fill("Mobile Model B")
|
||||
await expect(models.getByRole("button", { name: /Mobile Model A/ })).toHaveCount(0)
|
||||
await search.press("Enter")
|
||||
await expect(models).toBeHidden()
|
||||
await expect(model).toHaveText("Mobile Model B")
|
||||
await expect(composer.getByRole("textbox", { name: "Prompt", exact: true })).toBeFocused()
|
||||
|
||||
await model.click()
|
||||
await expect(search).toHaveValue("")
|
||||
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
|
||||
await expect(models.getByRole("button", { name: /Mobile Model B/ })).toHaveAttribute("aria-pressed", "true")
|
||||
await expect
|
||||
.poll(() => models.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(models).not.toHaveAttribute("data-transitioning")
|
||||
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
|
||||
await expect(models).toBeHidden()
|
||||
await expect(model).toBeFocused()
|
||||
|
||||
if (paid) {
|
||||
await model.click()
|
||||
await models.getByRole("button", { name: "Manage models", exact: true }).click()
|
||||
await expect(models).toBeHidden()
|
||||
await expect(page.getByRole("dialog", { name: "Manage models", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(1)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await model.click()
|
||||
await models.getByRole("button", { name: "Connect provider", exact: true }).click()
|
||||
await expect(models).toBeHidden()
|
||||
const providers = page.getByRole("dialog", { name: "Connect provider", exact: true })
|
||||
await expect(providers.getByRole("searchbox", { name: "Search providers", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(1)
|
||||
})
|
||||
}
|
||||
|
||||
test(`desktop model and variant pickers retain menus in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await openPickerSession(page, direction, true)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await variant.click()
|
||||
await page.getByRole("menuitemradio", { name: "high", exact: true }).click()
|
||||
await expect(variant).toHaveText("high")
|
||||
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
|
||||
const model = composer.locator('[data-action="composer-model"]')
|
||||
await expect(model).toHaveAttribute("data-control-type", "popover")
|
||||
await model.click()
|
||||
await expect(page.getByPlaceholder("Search models", { exact: true })).toBeFocused()
|
||||
await page.getByRole("menuitemradio", { name: /Mobile Model B/ }).click()
|
||||
await expect(model).toHaveText("Mobile Model B")
|
||||
await expect(page.locator('[data-slot="mobile-drawer-content"]')).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("touch composer pickers", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true })
|
||||
|
||||
test("model and default variant open with one tap", async ({ page }) => {
|
||||
await openPickerSession(page, "ltr", true)
|
||||
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
const variant = composer.getByRole("button", { name: "Choose model variant", exact: true })
|
||||
await variant.tap()
|
||||
const variants = page.getByRole("dialog", { name: "Choose model variant", exact: true })
|
||||
await variants.getByRole("button", { name: "default", exact: true }).tap()
|
||||
await expect(variants).toBeHidden()
|
||||
await expect(variant).toHaveText("default")
|
||||
await page.touchscreen.tap(5, 300)
|
||||
await expect(variant).toHaveCSS("pointer-events", "auto")
|
||||
await expect(variant).toHaveCSS("opacity", "1")
|
||||
await variant.tap()
|
||||
await expect(variants).toBeVisible()
|
||||
await expect
|
||||
.poll(() => variants.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await page.locator('[data-slot="mobile-drawer-overlay"]').tap({ position: { x: 10, y: 10 } })
|
||||
await expect(variants).toBeHidden()
|
||||
|
||||
await composer.locator('[data-action="composer-model"]').tap()
|
||||
const models = page.getByRole("dialog", { name: "Select model", exact: true })
|
||||
await expect(models.locator('[data-slot="model-selector-drawer"]')).toBeFocused()
|
||||
const search = models.getByRole("searchbox", { name: "Search models", exact: true })
|
||||
await expect(search).not.toBeFocused()
|
||||
await search.tap()
|
||||
await expect(search).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
async function openPickerSession(page: Page, direction: "ltr" | "rtl", paid: boolean) {
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions: fixture.sessions.map((session) => ({
|
||||
...session,
|
||||
model: { id: "mobile-a", providerID: "opencode", variant: "balanced" },
|
||||
})),
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: Object.fromEntries(
|
||||
["a", "b"].map((id) => [
|
||||
`mobile-${id}`,
|
||||
{
|
||||
id: `mobile-${id}`,
|
||||
name: `Mobile Model ${id.toUpperCase()}`,
|
||||
cost: { input: paid ? 1 : 0, output: paid ? 1 : 0 },
|
||||
limit: { context: 200_000 },
|
||||
variants: { balanced: {}, high: {} },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "mobile-a" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await installStressSessionTabs(page)
|
||||
await page.addInitScript((direction) => {
|
||||
localStorage.setItem("opencode-color-scheme", direction === "rtl" ? "dark" : "light")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
user: ["a", "b"].map((id) => ({ providerID: "opencode", modelID: `mobile-${id}`, visibility: "show" })),
|
||||
recent: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
}, direction)
|
||||
await openWithDirection(page, sessionHref(fixture.targetID), direction)
|
||||
await expectAppVisible(page.getByRole("textbox", { name: "Prompt", exact: true }))
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { sessionHref } from "../utils/app"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const scheme of ["light", "dark"] as const) {
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`mobile session options are compact and readable in ${scheme} ${direction}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((scheme) => {
|
||||
localStorage.setItem("opencode-color-scheme", scheme)
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
|
||||
}, scheme)
|
||||
await openWithDirection(page, sessionHref(fixture.targetID), direction)
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const more = tabs.getByRole("tab", { name: "More...", exact: true })
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Session")
|
||||
await more.click()
|
||||
const drawer = page.getByRole("dialog", { name: "More options", exact: true })
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
await expect(drawer.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
|
||||
await expect(drawer.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
|
||||
await expect(drawer.getByRole("heading", { name: "More options", exact: true })).toHaveCSS("width", "1px")
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme)
|
||||
await expect
|
||||
.poll(() =>
|
||||
drawer.evaluate((element) => {
|
||||
const options = element.querySelector('[data-slot="session-mobile-view-options"]')!
|
||||
|
||||
return getComputedStyle(options).backgroundColor !== getComputedStyle(element).backgroundColor
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await drawer.screenshot({ path: testInfo.outputPath("options.png") })
|
||||
await testInfo.attach("options", { path: testInfo.outputPath("options.png"), contentType: "image/png" })
|
||||
|
||||
for (const name of ["Files", "Terminal", "Usage", "Session details"]) {
|
||||
const option = drawer.getByRole("button", { name, exact: true })
|
||||
await expect(option).toHaveCSS("height", "44px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
option.evaluate((element) => {
|
||||
const context = document.createElement("canvas").getContext("2d")!
|
||||
|
||||
function luminance(color: string) {
|
||||
context.clearRect(0, 0, 1, 1)
|
||||
context.fillStyle = color
|
||||
context.fillRect(0, 0, 1, 1)
|
||||
|
||||
const channels = Array.from(context.getImageData(0, 0, 1, 1).data)
|
||||
.slice(0, 3)
|
||||
.map((channel) => {
|
||||
const value = channel / 255
|
||||
|
||||
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
|
||||
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722
|
||||
}
|
||||
|
||||
const text = luminance(getComputedStyle(element).color)
|
||||
const surface = luminance(getComputedStyle(element.parentElement!).backgroundColor)
|
||||
|
||||
return (Math.max(text, surface) + 0.05) / (Math.min(text, surface) + 0.05)
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
for (const dismissal of ["backdrop", "drag"] as const) {
|
||||
await more.click()
|
||||
await expect
|
||||
.poll(() => drawer.evaluate((element) => new DOMMatrixReadOnly(getComputedStyle(element).transform).m42))
|
||||
.toBe(0)
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
|
||||
if (dismissal === "backdrop")
|
||||
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
|
||||
|
||||
if (dismissal === "drag") {
|
||||
const handle = await drawer.locator('[data-slot="mobile-drawer-handle"]').boundingBox()
|
||||
expect(handle).not.toBeNull()
|
||||
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2 + 1)
|
||||
await page.mouse.move(handle!.x + handle!.width / 2, 843)
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ for (const position of ["top", "bottom"] as const) {
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const more = tabs.getByRole("tab", { name: "More...", exact: true })
|
||||
const views = page.getByRole("dialog", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
|
||||
const message = page.locator(
|
||||
@@ -37,32 +38,39 @@ for (const position of ["top", "bottom"] as const) {
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(navigation).toHaveCount(1)
|
||||
await expect(message).toBeVisible()
|
||||
await expect(composer).toBeVisible()
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "More..."])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
|
||||
if (position === "top") {
|
||||
const titlebar = page.locator('[data-slot="titlebar-v2"]')
|
||||
await expect(titlebar).toHaveCSS("padding-top", "16px")
|
||||
await expect(titlebar).toHaveCSS("height", "44px")
|
||||
await expect(titlebar).toHaveCSS("padding-top", "8px")
|
||||
await expect(titlebar).toHaveCSS("height", "36px")
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
return !!bounds && bounds.x >= 1 && bounds.x <= 2 && bounds.width >= 386 && bounds.width <= 388
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const dock = await page.locator('[data-component="session-composer-dock"]').boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
if (!bar || !input || !dock || !panel) return false
|
||||
|
||||
if (position === "bottom")
|
||||
return bar.y >= dock.y + dock.height && Math.abs(bar.y + bar.height - panel.y - panel.height) <= 1
|
||||
|
||||
return Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.locator("[data-session-title]")).toHaveCount(0)
|
||||
@@ -82,8 +90,21 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(drawer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(picker).toHaveCount(0)
|
||||
await expect(views).toBeVisible()
|
||||
await expect(more).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
for (const name of ["Files", "Terminal", "Usage", "Session details"])
|
||||
await expect(views.getByRole("button", { name, exact: true })).toBeVisible()
|
||||
await expect(views).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(views).toBeHidden()
|
||||
await expect(more).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(more).toBeFocused()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await more.press("Enter")
|
||||
await views.getByRole("button", { name: "Usage", exact: true }).click()
|
||||
await expect(views).toBeHidden()
|
||||
await expect(picker).toHaveText("More...")
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
const usage = page.locator('[data-slot="session-usage-content"]')
|
||||
await expect(usage).toHaveCSS("padding-top", "16px")
|
||||
@@ -92,24 +113,32 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(views.getByRole("button", { name: "Usage", exact: true })).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(views.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await views.evaluate((element) => element.setAttribute("data-drawer-probe", "single"))
|
||||
await views.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details).toHaveAttribute("data-drawer-probe", "single")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(1)
|
||||
await expect(page.locator('[data-slot="mobile-drawer-overlay"]')).toHaveCount(1)
|
||||
await expect(details.locator('[data-slot="mobile-panel-header"]')).toHaveCount(0)
|
||||
await expect(details.getByRole("button", { name: "Close", exact: true })).toHaveCount(0)
|
||||
await expect(details.getByRole("heading", { name: "Session details", exact: true })).toHaveCSS("width", "1px")
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await page.locator('[data-slot="mobile-drawer-overlay"]').click({ position: { x: 10, y: 10 } })
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await views.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details).not.toHaveAttribute("data-transitioning")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await views.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
@@ -118,18 +147,23 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(page.locator('[data-slot="session-review-header"]')).toHaveCSS("padding-left", "8px")
|
||||
await expect(composer).toBeHidden()
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await views.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(picker).toHaveText("Changes")
|
||||
await expect(page.getByText("No uncommitted changes yet", { exact: true })).toBeVisible()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Files", exact: true }).click()
|
||||
await expect(views).toBeHidden()
|
||||
await expect(picker).toHaveText("More...")
|
||||
await expect(page.getByRole("combobox", { name: "Filter files", exact: true })).toBeVisible()
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
await more.click()
|
||||
await expect(views.getByRole("button", { name: "Files", exact: true })).toHaveAttribute("aria-pressed", "true")
|
||||
await views.getByRole("button", { name: "Terminal", exact: true }).click()
|
||||
await expect(views).toBeHidden()
|
||||
const panel = page.locator("#terminal-panel")
|
||||
await expect(panel).toHaveAttribute("data-opened", "true")
|
||||
await expect(panel.getByRole("tab", { name: /Terminal 1/ })).toBeVisible()
|
||||
@@ -146,14 +180,14 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(picker).toHaveText("More...")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveAttribute("data-cache-probe", "original")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Session")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(picker).toHaveText("Terminal")
|
||||
await expect(picker).toHaveText("More...")
|
||||
await panel.getByRole("button", { name: "Close terminal", exact: true }).click()
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(panel).toBeHidden()
|
||||
@@ -174,18 +208,20 @@ for (const position of ["top", "bottom"] as const) {
|
||||
}
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await views.getByRole("button", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
await openTab(fixture.expected.sourceTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("More...")
|
||||
await openTab(fixture.expected.targetTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await openTab(fixture.expected.sourceTitle)
|
||||
await expect(picker).toHaveText("Session")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("Files")
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Files", exact: true }).click()
|
||||
await expect(picker).toHaveText("More...")
|
||||
await openTabs()
|
||||
await drawer.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL("/")
|
||||
@@ -220,15 +256,15 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
.click()
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const more = tabs.getByRole("tab", { name: "More...", exact: true })
|
||||
const views = page.getByRole("dialog", { name: "More options", exact: true })
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await details.getByRole("button", { name: "No changes", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Changes")
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Files", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Open files", exact: true }).getByRole("tab", { name: "README.md", exact: true }),
|
||||
).toHaveAttribute("aria-selected", "true")
|
||||
@@ -244,7 +280,8 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
|
||||
await page.reload()
|
||||
await expect(launcher).toHaveAttribute("aria-selected", "true")
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await more.click()
|
||||
await views.getByRole("button", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await files.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
@@ -268,14 +305,17 @@ test("a narrow-screen palette pick keeps the view and dock, and the side region
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const terminal = page.locator("#terminal-panel")
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
const views = page.getByRole("dialog", { name: "More options", exact: true })
|
||||
await tabs.getByRole("tab", { name: "More...", exact: true }).click()
|
||||
await views.getByRole("button", { name: "Terminal", exact: true }).click()
|
||||
await expect(views).toBeHidden()
|
||||
await expect(terminal.locator("textarea")).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+p")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox").fill("README")
|
||||
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Terminal")
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("More...")
|
||||
await expect(terminal).toHaveAttribute("data-opened", "true")
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
|
||||
@@ -859,6 +859,45 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(pending).toContainText(followUp)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
|
||||
const bubble = pending.locator('[data-slot="user-message-text"]')
|
||||
|
||||
const colors =
|
||||
delivery === "steer"
|
||||
? {
|
||||
delivered: await userRow(page, userID)
|
||||
.locator('[data-slot="user-message-text"]')
|
||||
.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
text: getComputedStyle(element).color,
|
||||
})),
|
||||
pending: await bubble.evaluate((element) => {
|
||||
const probe = document.createElement("span")
|
||||
probe.style.backgroundColor = "var(--v2-background-bg-layer-02)"
|
||||
probe.style.color = "var(--v2-text-text-base)"
|
||||
element.appendChild(probe)
|
||||
|
||||
const colors = {
|
||||
background: getComputedStyle(probe).backgroundColor,
|
||||
text: getComputedStyle(probe).color,
|
||||
}
|
||||
|
||||
probe.remove()
|
||||
|
||||
return colors
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
|
||||
if (colors) {
|
||||
await expect(bubble).toHaveCSS("background-color", colors.pending.background)
|
||||
await expect(bubble).toHaveCSS("color", colors.pending.text)
|
||||
await pending.hover()
|
||||
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(
|
||||
/^Pending\s*·\s*Build\s*·\s*Queue Model$/,
|
||||
)
|
||||
await bubble.evaluate((element) => (element.dataset.deliveryMarker = "pending"))
|
||||
}
|
||||
|
||||
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
|
||||
mock.emit("session.step.started", {
|
||||
sessionID,
|
||||
@@ -921,6 +960,18 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toHaveCount(1)
|
||||
|
||||
if (colors) {
|
||||
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
|
||||
await expect(pending.locator('[data-slot="user-message-meta"]')).toHaveText(/^Build\s*·\s*Queue Model$/)
|
||||
await expect(bubble).toHaveCSS("background-color", colors.delivered.background)
|
||||
await expect(bubble).toHaveCSS("color", colors.delivered.text)
|
||||
await expect(bubble).toHaveCSS("transition-property", "background-color, color")
|
||||
await transcript.screenshot({ path: testInfo.outputPath("delivered-steer.png") })
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await expect(bubble).toHaveCSS("transition-duration", "0s")
|
||||
}
|
||||
|
||||
await expect(transcript.locator('[data-timeline-row="UserMessage"]')).toHaveCount(2)
|
||||
await expect(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools })).toHaveAttribute(
|
||||
"data-message-id",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { fixture, installStressSessionTabs, mockStressTimeline } from "../utils/session-fixture"
|
||||
import { sessionHref, draftHref } from "../utils/app"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`bottom mobile navigation adds no extra top panel gutter in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { mobileTitlebarPosition: "bottom" } }))
|
||||
})
|
||||
await openWithDirection(page, "/", direction)
|
||||
const panel = page.locator('[data-slot="home-panel"]')
|
||||
await expect(panel).toHaveCSS("--shell-top-inset", "0px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const titlebar = await page.locator('[data-slot="titlebar-v2"]').boundingBox()
|
||||
const main = await page.getByRole("main").boundingBox()
|
||||
|
||||
return titlebar && main && titlebar.y >= main.y + main.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await panel.boundingBox()
|
||||
const main = await page.getByRole("main").boundingBox()
|
||||
|
||||
return bounds && main && bounds.y - main.y
|
||||
})
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
for (const screen of [
|
||||
{ name: "home", href: () => "/", panel: (page: Page) => page.locator('[data-slot="home-panel"]') },
|
||||
{
|
||||
name: "new-session",
|
||||
href: () => draftHref("shell-spacing-draft"),
|
||||
panel: (page: Page) => page.locator('[data-component="new-session"]'),
|
||||
},
|
||||
{
|
||||
name: "session",
|
||||
href: () => sessionHref(fixture.targetID),
|
||||
panel: (page: Page) => page.locator('[data-slot="session-chat-panel"]'),
|
||||
},
|
||||
{ name: "settings", href: () => "/settings", panel: (page: Page) => page.getByTestId("settings-screen") },
|
||||
]) {
|
||||
test(`${screen.name} keeps an 8px top gutter and only inline border clearance on mobile in ${direction}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
|
||||
if (screen.name === "new-session") await installStressSessionTabs(page, { draftID: "shell-spacing-draft" })
|
||||
await openWithDirection(page, screen.href(), direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
|
||||
const panel = screen.panel(page)
|
||||
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await panel.boundingBox()
|
||||
const main = await page.getByRole("main").boundingBox()
|
||||
|
||||
return bounds && main && bounds.y - main.y
|
||||
})
|
||||
.toBe(8)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await panel.boundingBox()
|
||||
const main = await page.getByRole("main").boundingBox()
|
||||
|
||||
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
|
||||
})
|
||||
.toEqual({ x: 1, width: 2 })
|
||||
|
||||
// The existing desktop gutter must return at the responsive breakpoint.
|
||||
await page.setViewportSize({ width: 768, height: 900 })
|
||||
await expect(panel).toHaveCSS("--shell-inline-inset", "8px")
|
||||
await expect(panel).toHaveCSS("--shell-top-inset", "8px")
|
||||
|
||||
if (screen.name === "session") return
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await panel.boundingBox()
|
||||
const main = await page.getByRole("main").boundingBox()
|
||||
|
||||
return bounds && main && { x: bounds.x - main.x, width: main.width - bounds.width }
|
||||
})
|
||||
.toEqual({ x: 8, width: 16 })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -56,19 +56,39 @@ export type ComposerSession = {
|
||||
clear: (messageID: string) => void
|
||||
}
|
||||
api: {
|
||||
command: (input: Parameters<ServerSDK["api"]["session"]["command"]>[0]) => Promise<unknown>
|
||||
shell: (input: Parameters<ServerSDK["api"]["session"]["shell"]>[0]) => Promise<unknown>
|
||||
switchAgent: (input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0]) => Promise<unknown>
|
||||
switchModel: (input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0]) => Promise<unknown>
|
||||
command: (
|
||||
input: Parameters<ServerSDK["api"]["session"]["command"]>[0],
|
||||
) => ReturnType<ServerSDK["api"]["session"]["command"]>
|
||||
shell: (
|
||||
input: Parameters<ServerSDK["api"]["session"]["shell"]>[0],
|
||||
) => ReturnType<ServerSDK["api"]["session"]["shell"]>
|
||||
switchAgent: (
|
||||
input: Parameters<ServerSDK["api"]["session"]["switchAgent"]>[0],
|
||||
) => ReturnType<ServerSDK["api"]["session"]["switchAgent"]>
|
||||
switchModel: (
|
||||
input: Parameters<ServerSDK["api"]["session"]["switchModel"]>[0],
|
||||
) => ReturnType<ServerSDK["api"]["session"]["switchModel"]>
|
||||
revert: {
|
||||
commit: (
|
||||
input: Parameters<ServerSDK["api"]["session"]["revert"]["commit"]>[0],
|
||||
) => ReturnType<ServerSDK["api"]["session"]["revert"]["commit"]>
|
||||
}
|
||||
}
|
||||
data: {
|
||||
location: { command: Pick<Data["location"]["command"], "list"> }
|
||||
session: {
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
|
||||
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => ReturnType<Data["session"]["prompt"]>
|
||||
setStatus: Data["session"]["setStatus"]
|
||||
}
|
||||
}
|
||||
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
current: Accessor<
|
||||
| {
|
||||
agent?: string
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
revert?: { messageID: string }
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
admitted: (messageID: string) => boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Accessor } from "solid-js"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { authTokenFromCredentials } from "@/runtime/server/api"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import type { ComposerControls } from "../adapter"
|
||||
|
||||
@@ -29,7 +30,7 @@ export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
|
||||
// normalizes the separators and returns the resolved path.
|
||||
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
|
||||
url.searchParams.set("location[directory]", location().directory)
|
||||
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
|
||||
url.searchParams.set("path", `${info.paths.tmp}/uploads/${uuid()}/${file.name}`)
|
||||
|
||||
return write(url, file, server.conn.http.password, report, signal)
|
||||
},
|
||||
@@ -50,6 +51,7 @@ function write(url: URL, file: File, password: string | undefined, report: (load
|
||||
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
// SAFETY: a 200 from `fs.write` is its declared success body, `Location.response(FileSystem.Write)`.
|
||||
resolve((xhr.response as { data: { path: string } }).data.path)
|
||||
})
|
||||
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
|
||||
|
||||
@@ -1,123 +1,52 @@
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { FileSelection } from "@/workspaces/files/model"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { uuid } from "@/runtime/persistence/uuid"
|
||||
import { FileSelection } from "@/workspaces/files/types"
|
||||
import { durableNote, LegacyBrowserNote, NoteComment, type ContextItem } from "./schema"
|
||||
|
||||
export type PromptFileComment = {
|
||||
type?: "file"
|
||||
path: string
|
||||
selection?: FileSelection
|
||||
comment: string
|
||||
preview?: string
|
||||
origin?: "review" | "file"
|
||||
}
|
||||
export const PromptFileComment = Persistence.struct({
|
||||
type: Schema.optional(Schema.Literal("file")),
|
||||
path: Schema.String,
|
||||
selection: Persistence.optional(FileSelection),
|
||||
comment: Schema.String,
|
||||
preview: Persistence.optional(Schema.String),
|
||||
origin: Persistence.optional(Schema.Literals(["review", "file"])),
|
||||
})
|
||||
|
||||
export type PromptFileComment = typeof PromptFileComment.Type
|
||||
|
||||
export type PromptComment = PromptFileComment | NoteComment
|
||||
|
||||
const decodeNoteComment = Schema.decodeUnknownOption(NoteComment)
|
||||
|
||||
const decodeLegacyBrowserNote = Schema.decodeUnknownOption(LegacyBrowserNote)
|
||||
|
||||
/** An attachment the model receives as a path on the server rather than inline bytes. */
|
||||
export type PromptAttachmentReference = {
|
||||
name: string
|
||||
mime: string
|
||||
path: string
|
||||
}
|
||||
export const PromptAttachmentReference = Persistence.struct({
|
||||
name: Schema.String,
|
||||
mime: Schema.String,
|
||||
path: Schema.String,
|
||||
})
|
||||
|
||||
function selection(selection: unknown) {
|
||||
if (!selection || typeof selection !== "object") return undefined
|
||||
const startLine = Number((selection as FileSelection).startLine)
|
||||
const startChar = Number((selection as FileSelection).startChar)
|
||||
const endLine = Number((selection as FileSelection).endLine)
|
||||
const endChar = Number((selection as FileSelection).endChar)
|
||||
export type PromptAttachmentReference = typeof PromptAttachmentReference.Type
|
||||
|
||||
if (![startLine, startChar, endLine, endChar].every(Number.isFinite)) return undefined
|
||||
// Presentation metadata needs a comments list, which prompts from other clients lack; a malformed comment or
|
||||
// attachment is skipped rather than discarding the rest.
|
||||
const decodePromptPresentation = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
displayText: Schema.String,
|
||||
comments: Schema.Array(Schema.Unknown),
|
||||
attachments: Persistence.array(PromptAttachmentReference),
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
startLine,
|
||||
startChar,
|
||||
endLine,
|
||||
endChar,
|
||||
} satisfies FileSelection
|
||||
}
|
||||
const decodePromptComment = Schema.decodeUnknownOption(Schema.Union([NoteComment, LegacyBrowserNote, PromptFileComment]))
|
||||
|
||||
export function createCommentMetadata(input: PromptFileComment) {
|
||||
return {
|
||||
opencodeComment: {
|
||||
path: input.path,
|
||||
selection: input.selection,
|
||||
comment: input.comment,
|
||||
preview: input.preview,
|
||||
origin: input.origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function readCommentMetadata(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
const meta = (value as { opencodeComment?: unknown }).opencodeComment
|
||||
|
||||
if (!meta || typeof meta !== "object") return
|
||||
const path = (meta as { path?: unknown }).path
|
||||
const comment = (meta as { comment?: unknown }).comment
|
||||
|
||||
if (typeof path !== "string" || typeof comment !== "string") return
|
||||
const preview = (meta as { preview?: unknown }).preview
|
||||
const origin = (meta as { origin?: unknown }).origin
|
||||
|
||||
return {
|
||||
path,
|
||||
selection: selection((meta as { selection?: unknown }).selection),
|
||||
comment,
|
||||
preview: typeof preview === "string" ? preview : undefined,
|
||||
origin: origin === "review" || origin === "file" ? origin : undefined,
|
||||
} satisfies PromptComment
|
||||
}
|
||||
|
||||
export function readPromptPresentation(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
const displayText = (value as { displayText?: unknown }).displayText
|
||||
const comments = (value as { comments?: unknown }).comments
|
||||
|
||||
if (typeof displayText !== "string" || !Array.isArray(comments)) return
|
||||
const attachments = (value as { attachments?: unknown }).attachments
|
||||
|
||||
return {
|
||||
displayText,
|
||||
attachments: (Array.isArray(attachments) ? attachments : []).flatMap((item): PromptAttachmentReference[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const name = (item as { name?: unknown }).name
|
||||
const mime = (item as { mime?: unknown }).mime
|
||||
const path = (item as { path?: unknown }).path
|
||||
|
||||
if (typeof name !== "string" || typeof mime !== "string" || typeof path !== "string") return []
|
||||
|
||||
return [{ name, mime, path }]
|
||||
}),
|
||||
comments: comments.flatMap((item): PromptComment[] => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
|
||||
if ((item as { type?: unknown }).type === "note") return Option.toArray(decodeNoteComment(item))
|
||||
|
||||
if ((item as { type?: unknown }).type === "browser") return Option.toArray(decodeLegacyBrowserNote(item))
|
||||
const path = (item as { path?: unknown }).path
|
||||
const comment = (item as { comment?: unknown }).comment
|
||||
|
||||
if (typeof path !== "string" || typeof comment !== "string") return []
|
||||
const preview = (item as { preview?: unknown }).preview
|
||||
const origin = (item as { origin?: unknown }).origin
|
||||
|
||||
return [
|
||||
{
|
||||
path,
|
||||
comment,
|
||||
selection: selection((item as { selection?: unknown }).selection),
|
||||
preview: typeof preview === "string" ? preview : undefined,
|
||||
origin: origin === "review" || origin === "file" ? origin : undefined,
|
||||
},
|
||||
]
|
||||
}),
|
||||
}
|
||||
export function readPromptPresentation(metadata: SessionMessageUser["metadata"]) {
|
||||
return Option.getOrUndefined(
|
||||
Option.map(decodePromptPresentation(metadata), (presentation) => ({
|
||||
displayText: presentation.displayText,
|
||||
attachments: presentation.attachments,
|
||||
comments: presentation.comments.flatMap((item): PromptComment[] => Option.toArray(decodePromptComment(item))),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
export function formatAttachmentReference(input: PromptAttachmentReference) {
|
||||
@@ -132,7 +61,7 @@ export function formatNoteComment(input: NoteComment) {
|
||||
/** Restores a sent comment to the composer, for example after a revert or fork. */
|
||||
export function commentContextItem(comment: PromptComment): ContextItem {
|
||||
// The message may predate this app process, so a note's live references can no longer be trusted.
|
||||
if (comment.type === "note") return { ...durableNote(comment), commentID: crypto.randomUUID() }
|
||||
if (comment.type === "note") return { ...durableNote(comment), commentID: uuid() }
|
||||
|
||||
return {
|
||||
type: "file",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -66,6 +67,7 @@ function ComposerModelControl(props: {
|
||||
onUnpaidClick: () => void
|
||||
}) {
|
||||
const shouldAnimate = createMemo<boolean>((previous) => previous ?? props.loading)
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const content = () => (
|
||||
<>
|
||||
@@ -87,6 +89,7 @@ function ComposerModelControl(props: {
|
||||
return (
|
||||
<Show when={!props.loading}>
|
||||
<Tooltip
|
||||
inactive={mobile()}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
value={
|
||||
@@ -97,7 +100,7 @@ function ComposerModelControl(props: {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.paid}
|
||||
when={props.paid || mobile()}
|
||||
fallback={
|
||||
<Button
|
||||
data-action="composer-model"
|
||||
@@ -115,6 +118,7 @@ function ComposerModelControl(props: {
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
model={props.model}
|
||||
unpaid={!props.paid}
|
||||
trigger={(triggerProps) => (
|
||||
<Button
|
||||
{...triggerProps}
|
||||
@@ -124,7 +128,7 @@ function ComposerModelControl(props: {
|
||||
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
|
||||
classList={{ "animate-in fade-in": shouldAnimate() }}
|
||||
data-action="composer-model"
|
||||
data-control-type="popover"
|
||||
data-control-type={mobile() ? "drawer" : "popover"}
|
||||
>
|
||||
{content()}
|
||||
</Button>
|
||||
|
||||
@@ -25,9 +25,11 @@
|
||||
);
|
||||
}
|
||||
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
@media (hover: hover) {
|
||||
[data-component="composer"]:not(:hover) .composer-variant-default:not(:focus-visible, [data-expanded]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
|
||||
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
onMount,
|
||||
Show,
|
||||
Suspense,
|
||||
lazy,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
@@ -46,6 +48,12 @@ import { isAttachment } from "../prompt-parts"
|
||||
import "../attachments/attachments.css"
|
||||
import "./editor.css"
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
export type {
|
||||
ComposerAttachment,
|
||||
ComposerComment,
|
||||
@@ -350,6 +358,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<ComposerEditorConfiguredSelect
|
||||
mobileDrawer
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
@@ -804,6 +813,7 @@ export function ComposerEditorAddMenu(props: {
|
||||
}
|
||||
|
||||
function ComposerEditorConfiguredSelect(props: {
|
||||
mobileDrawer?: boolean
|
||||
title: string
|
||||
keybind?: string[]
|
||||
control: ComposerSelectControl
|
||||
@@ -815,6 +825,7 @@ function ComposerEditorConfiguredSelect(props: {
|
||||
|
||||
return (
|
||||
<ComposerEditorSelect
|
||||
mobileDrawer={props.mobileDrawer}
|
||||
title={props.title}
|
||||
class={props.class}
|
||||
keybind={props.control.keybind?.() ?? props.keybind}
|
||||
@@ -831,6 +842,7 @@ function ComposerEditorConfiguredSelect(props: {
|
||||
}
|
||||
|
||||
export function ComposerEditorSelect(props: {
|
||||
mobileDrawer?: boolean
|
||||
title: string
|
||||
keybind?: string[]
|
||||
options: ComposerOption[]
|
||||
@@ -840,8 +852,36 @@ export function ComposerEditorSelect(props: {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const [store, setStore] = createStore({ open: false, loaded: false })
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
|
||||
const setOpen = (open: boolean) => {
|
||||
setStore("open", open)
|
||||
|
||||
if (open) setStore("loaded", true)
|
||||
props.onOpenChange?.(open)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!mobile() && store.open) setOpen(false)
|
||||
})
|
||||
|
||||
const content = () => (
|
||||
<>
|
||||
{props.currentIcon}
|
||||
<span class="truncate capitalize leading-5">
|
||||
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
|
||||
</span>
|
||||
<span class="-ms-0.5 -me-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
inactive={props.mobileDrawer && mobile()}
|
||||
placement="top"
|
||||
value={
|
||||
<>
|
||||
@@ -850,36 +890,83 @@ export function ComposerEditorSelect(props: {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
<Show
|
||||
when={props.mobileDrawer && mobile()}
|
||||
fallback={
|
||||
<Menu gutter={6} modal={false} placement="top-start" onOpenChange={props.onOpenChange}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
|
||||
aria-label={props.title}
|
||||
>
|
||||
{content()}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
|
||||
<For each={props.options}>
|
||||
{(option) => (
|
||||
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
|
||||
{option.label}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class={`max-w-[220px] justify-start ![font-weight:440] ${props.class ?? ""}`}
|
||||
aria-label={props.title}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={store.open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{props.currentIcon}
|
||||
<span class="truncate capitalize leading-5">
|
||||
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
|
||||
</span>
|
||||
<span class="-ms-0.5 -me-1 flex shrink-0">
|
||||
<Icon name="chevron-down" />
|
||||
</span>
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Menu.RadioGroup value={props.current} onChange={props.onSelect}>
|
||||
<For each={props.options}>
|
||||
{(option) => (
|
||||
<Menu.RadioItem value={option.id} class="capitalize" closeOnSelect>
|
||||
{option.label}
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
{content()}
|
||||
</Button>
|
||||
<Show when={store.loaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
hideHeader
|
||||
title={props.title}
|
||||
open={store.open}
|
||||
onOpenChange={setOpen}
|
||||
returnFocus={() => trigger}
|
||||
>
|
||||
<div class="flex flex-col overflow-hidden rounded-lg border border-v2-border-border-base bg-v2-background-bg-layer-02 divide-y divide-v2-border-border-base">
|
||||
<For each={props.options}>
|
||||
{(option) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !h-10 !justify-start !rounded-none !px-3 !font-[440] capitalize focus-visible:!outline-offset-[-2px]"
|
||||
aria-pressed={props.current === option.id}
|
||||
data-state={props.current === option.id ? "pressed" : undefined}
|
||||
onClick={() => {
|
||||
props.onSelect(option.id)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
<Show when={props.current === option.id}>
|
||||
<Icon name="check" class="ms-auto" />
|
||||
</Show>
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Show>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,11 @@ function session(input: {
|
||||
input.calls.push("shell")
|
||||
}),
|
||||
command: input.command ?? (async () => undefined),
|
||||
revert: {
|
||||
commit: async () => {
|
||||
input.calls.push("revert-commit")
|
||||
},
|
||||
},
|
||||
},
|
||||
data: {
|
||||
location: { command: { list: () => [] } },
|
||||
@@ -138,6 +143,16 @@ function session(input: {
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
await input.prompt?.(value)
|
||||
|
||||
// The admitted inbox item, as the server returns it.
|
||||
return {
|
||||
id: value.id ?? "msg_admitted",
|
||||
sessionID: value.sessionID,
|
||||
time: { created: 0 },
|
||||
type: "user" as const,
|
||||
payload: { text: value.text },
|
||||
delivery: value.delivery ?? "steer",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -183,6 +198,11 @@ describe("Composer submission", () => {
|
||||
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
|
||||
calls: ["switch-model"],
|
||||
},
|
||||
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
|
||||
{
|
||||
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
|
||||
calls: ["switch-agent", "revert-commit", "switch-model"],
|
||||
},
|
||||
])("applies the selection before sending one captured value: $calls", async (row) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
|
||||
@@ -332,6 +332,8 @@ function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<type
|
||||
submission.target().mode.set("normal")
|
||||
input.setMode("normal")
|
||||
input.closePopover()
|
||||
|
||||
if (window.matchMedia("(max-width: 767px)").matches) input.editor()?.blur()
|
||||
}
|
||||
|
||||
function restoreSubmission(
|
||||
@@ -455,6 +457,7 @@ async function applySelection(
|
||||
session: ComposerSession,
|
||||
selection: ComposerSelection,
|
||||
track?: ModelSelection["trackSessionCommit"],
|
||||
beforeModel?: () => Promise<void>,
|
||||
) {
|
||||
const cancel = track?.(session.id, selection)
|
||||
|
||||
@@ -465,6 +468,8 @@ async function applySelection(
|
||||
await session.api.switchAgent({ sessionID: session.id, agent: selection.agent })
|
||||
}
|
||||
|
||||
await beforeModel?.()
|
||||
|
||||
// The server deduplicates unchanged selections; cached SSE state may still be behind an earlier switch.
|
||||
await session.api.switchModel({
|
||||
sessionID: session.id,
|
||||
@@ -489,10 +494,15 @@ async function sendPrompt(
|
||||
// selection applies now; a queued follow-up must not reconfigure the turn it
|
||||
// waits behind, so it runs with the session selection at delivery time (the
|
||||
// intended selection stays recorded in its metadata).
|
||||
if (value.delivery === "steer") {
|
||||
await applySelection(session, value.selection, track)
|
||||
// Like the TUI, a staged revert settles after the agent switch and before the model switch and admission. The
|
||||
// server would otherwise commit it on admission and delete every row from its boundary on, the model switch too.
|
||||
const settle = async () => {
|
||||
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
|
||||
}
|
||||
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
|
||||
else await settle()
|
||||
|
||||
const admission = {
|
||||
id: value.id,
|
||||
sessionID: session.id,
|
||||
|
||||
@@ -20,8 +20,9 @@ export function Home() {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="home-panel"
|
||||
class={`
|
||||
mx-2 mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
mx-[var(--shell-inline-inset,8px)] mb-[var(--shell-bottom-inset,8px)] mt-[var(--shell-top-inset,8px)] flex min-h-0 flex-1 flex-col self-stretch overflow-hidden rounded-[10px]
|
||||
bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Predicate } from "effect"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
@@ -47,7 +48,10 @@ export function createNewSessionComposerAdapter(props: {
|
||||
async start(selection, submission, message) {
|
||||
const draftID = props.draftID
|
||||
const currentDirectory = location().directory
|
||||
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
|
||||
const projectDirectory =
|
||||
data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
|
||||
|
||||
const worktree = props.worktree()
|
||||
const branch = props.branch()
|
||||
const mcp = props.mcp.capture()
|
||||
@@ -163,6 +167,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
|
||||
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
|
||||
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
|
||||
revert: { commit: (input) => afterCreation(() => serverSDK.api.session.revert.commit(input)) },
|
||||
},
|
||||
data: {
|
||||
location: data.location,
|
||||
@@ -239,16 +244,16 @@ async function resolveSessionDirectory(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
function errorMessage(language: ReturnType<typeof useLanguage>, cause: unknown) {
|
||||
if (Predicate.hasProperty(cause, "message") && Predicate.isString(cause.message)) return cause.message
|
||||
|
||||
if (error && typeof error === "object" && "data" in error) {
|
||||
const data = (error as { data?: { message?: string } }).data
|
||||
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (
|
||||
Predicate.hasProperty(cause, "data") &&
|
||||
Predicate.hasProperty(cause.data, "message") &&
|
||||
Predicate.isString(cause.data.message) &&
|
||||
cause.data.message
|
||||
)
|
||||
return cause.data.message
|
||||
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export function PromptProjectSelector(props: {
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-1 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
<input
|
||||
ref={(el) => props.controller.setSearchRef(el)}
|
||||
@@ -402,16 +402,16 @@ export function PromptProjectSelector(props: {
|
||||
<Show when={props.controller.search().trim()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="flex size-5 items-center justify-center rounded-sm bg-transparent text-v2-icon-icon-faint transition-colors hover:text-v2-icon-icon-base focus-visible:text-v2-icon-icon-base active:text-v2-icon-icon-base"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => props.controller.clearSearch()}
|
||||
aria-label={props.controller.labels.clear()}
|
||||
>
|
||||
<Icon name="close-small" size="small" />
|
||||
<Icon name="circle-xmark" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="max-h-[224px] overflow-y-auto">
|
||||
<div class="my-0.5 max-h-[224px] overflow-y-auto">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
@@ -451,8 +451,8 @@ export function PromptProjectSelector(props: {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col">
|
||||
<div class="-mx-0.5 h-px bg-v2-border-border-muted" />
|
||||
<div class="flex flex-col pt-0.5">
|
||||
<Show
|
||||
when={props.controller.servers().length > 1}
|
||||
fallback={
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function NewSessionPage(props: { draftId: string }) {
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<NewSessionView composer={model} project={project} workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,8 +66,9 @@ export const OpenCodeZen = {
|
||||
|
||||
export const LoginMethods = {
|
||||
render: renderConnection("openai", [
|
||||
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
||||
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
||||
{ type: "oauth", label: "Sign in with ChatGPT" },
|
||||
{ type: "oauth", label: "Codex browser (legacy)" },
|
||||
{ type: "oauth", label: "Codex device code (legacy)" },
|
||||
{ type: "key", label: "API key" },
|
||||
]),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
[data-slot="mobile-drawer-content"]:has([data-slot="model-selector-drawer"]) {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
[data-slot="model-selector-drawer"] {
|
||||
/* Keep the gutter inside each scroll boundary so outlines are not clipped. */
|
||||
[data-slot="model-selector-search"],
|
||||
.settings-models,
|
||||
[data-slot="model-selector-actions"] {
|
||||
padding-left: max(12px, env(safe-area-inset-left, 0px));
|
||||
padding-right: max(12px, env(safe-area-inset-right, 0px));
|
||||
}
|
||||
|
||||
[data-component="settings-list"][data-variant="catalog"] {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
[data-component="settings-row"] {
|
||||
width: calc(100% + 24px);
|
||||
margin-inline: -12px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.settings-models-console-groups {
|
||||
margin-inline-start: 4px;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
|
||||
.provider-model-group-trigger,
|
||||
.provider-model-group-models {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.provider-model-group-models > [data-component="settings-list"] {
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show, Suspense, lazy, on } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { popularProviders } from "@/providers/catalog/providers"
|
||||
@@ -24,8 +25,20 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import {
|
||||
CONSOLE_GROUP_KEY,
|
||||
consoleModelGroup,
|
||||
ProviderModelIcon,
|
||||
ProviderModelSections,
|
||||
} from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
import "./select-dialog.css"
|
||||
|
||||
const MobilePanelDrawer = lazy(async () => {
|
||||
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
|
||||
|
||||
return { default: MobilePanelDrawer }
|
||||
})
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
provider === "opencode" && (!cost || cost.input === 0)
|
||||
@@ -54,6 +67,8 @@ const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { categ
|
||||
}
|
||||
|
||||
const ModelList: Component<{
|
||||
mobile?: boolean
|
||||
open?: boolean
|
||||
provider?: string
|
||||
onSelect: () => void
|
||||
model?: ModelState
|
||||
@@ -66,12 +81,21 @@ const ModelList: Component<{
|
||||
onSelect: props.onSelect,
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
const [store, setStore] = createStore<{ search: string; active: string; collapsed: Record<string, boolean> }>({
|
||||
search: "",
|
||||
active: "",
|
||||
collapsed: {} as Record<string, boolean>,
|
||||
active: props.mobile ? (controller.current() ?? "") : "",
|
||||
collapsed: {},
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!props.mobile || !open) return
|
||||
setStore({ search: "", active: controller.current() ?? "" })
|
||||
},
|
||||
),
|
||||
)
|
||||
const models = createMemo(() => controller.models(store.search))
|
||||
const modelGroups = createMemo(() => controller.groups(models()))
|
||||
const managed = createMemo(() => consoleModelGroup(controller.all()))
|
||||
@@ -110,7 +134,7 @@ const ModelList: Component<{
|
||||
if (item) controller.select(item)
|
||||
}
|
||||
|
||||
function ModelRows(props: { items: ModelItem[] }) {
|
||||
function ModelRows(props: { items: ModelItem[]; mobile?: boolean }) {
|
||||
return (
|
||||
<SettingsList variant="catalog">
|
||||
<For each={props.items}>
|
||||
@@ -119,6 +143,7 @@ const ModelList: Component<{
|
||||
type="button"
|
||||
data-component="settings-row"
|
||||
data-option-key={modelKey(item)}
|
||||
aria-pressed={controller.current() === modelKey(item)}
|
||||
class="-mx-4 w-[calc(100%+32px)] px-4 text-start first:rounded-t-lg last:rounded-b-lg hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||
onMouseEnter={() => setStore("active", modelKey(item))}
|
||||
@@ -128,6 +153,7 @@ const ModelList: Component<{
|
||||
<div data-slot="settings-row-copy">
|
||||
<div data-slot="settings-row-title" class="flex items-center gap-2">
|
||||
<Tooltip
|
||||
inactive={props.mobile}
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
openDelay={0}
|
||||
@@ -159,7 +185,7 @@ const ModelList: Component<{
|
||||
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="shrink-0 px-4 pt-px pb-3">
|
||||
<div data-slot="model-selector-search" class="shrink-0 pt-px pb-3" classList={{ "px-4": !props.mobile }}>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
type="search"
|
||||
@@ -167,7 +193,7 @@ const ModelList: Component<{
|
||||
class="!w-full self-stretch"
|
||||
placeholder={language.t("dialog.model.search.placeholder")}
|
||||
value={store.search}
|
||||
autofocus
|
||||
autofocus={!props.mobile}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
@@ -210,8 +236,12 @@ const ModelList: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div ref={(element) => (scrollRef = element)} class="settings-panel settings-models h-full px-4 pt-1 pb-4">
|
||||
<div class="relative min-h-0" classList={{ "flex-1": !props.mobile }}>
|
||||
<div
|
||||
ref={(element) => (scrollRef = element)}
|
||||
class="settings-panel settings-models pt-1 pb-4"
|
||||
classList={{ "h-full px-4": !props.mobile, "max-h-[min(360px,40dvh)]": props.mobile }}
|
||||
>
|
||||
<Show
|
||||
when={models().length > 0}
|
||||
fallback={<div class="settings-models-status">{language.t("dialog.model.empty")}</div>}
|
||||
@@ -222,7 +252,7 @@ const ModelList: Component<{
|
||||
expanded={expanded}
|
||||
disabled={store.search.length > 0}
|
||||
onExpandedChange={(key, value) => setStore("collapsed", key, !value)}
|
||||
rows={(items) => <ModelRows items={items} />}
|
||||
rows={(items) => <ModelRows items={items} mobile={props.mobile} />}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -238,10 +268,12 @@ type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||
export function ModelSelectorPopover(props: {
|
||||
provider?: string
|
||||
model?: ModelState
|
||||
unpaid?: boolean
|
||||
trigger: ModelSelectorTrigger
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const data = useData()
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
@@ -256,27 +288,171 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const connection = data.location.integration
|
||||
.list(location().ref)
|
||||
?.find((integration) => integration.id === "openai")
|
||||
?.connections[0]
|
||||
?.find((integration) => integration.id === "openai")?.connections[0]
|
||||
|
||||
return connection?.type === "credential" && connection.method === "oauth"
|
||||
}
|
||||
|
||||
const manage = async () => {
|
||||
const { DialogManageModels } = await import("./manage")
|
||||
void dialog.show(() => <DialogManageModels />)
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
|
||||
void dialog.show(() => <DialogConnectProvider directory={location().directory} />)
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverView
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
chatgptPlan={chatgptPlan()}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./manage").then((module) => {
|
||||
void dialog.show(() => <module.DialogManageModels />)
|
||||
})
|
||||
}}
|
||||
onClose={() => props.onClose?.()}
|
||||
/>
|
||||
<Show
|
||||
when={mobile()}
|
||||
fallback={
|
||||
<ModelSelectorPopoverView
|
||||
trigger={props.trigger}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
chatgptPlan={chatgptPlan()}
|
||||
select={controller.select}
|
||||
onManage={manage}
|
||||
onClose={() => props.onClose?.()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ModelSelectorDrawer
|
||||
trigger={props.trigger}
|
||||
model={props.model}
|
||||
provider={props.provider}
|
||||
onConnect={props.unpaid ? connect : undefined}
|
||||
chatgptPlan={chatgptPlan()}
|
||||
onClose={() => props.onClose?.()}
|
||||
onManage={manage}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSelectorDrawer(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
model?: ModelState
|
||||
provider?: string
|
||||
chatgptPlan?: boolean
|
||||
onClose: () => void
|
||||
onManage: () => void
|
||||
onConnect?: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
open: boolean
|
||||
loaded: boolean
|
||||
restoreTrigger: boolean
|
||||
action?: "select" | "manage" | "connect"
|
||||
}>({
|
||||
open: false,
|
||||
loaded: false,
|
||||
restoreTrigger: true,
|
||||
})
|
||||
|
||||
let trigger: HTMLDivElement | undefined
|
||||
let content: HTMLDivElement | undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={trigger} class="min-w-0">
|
||||
{props.trigger({
|
||||
"aria-haspopup": "dialog",
|
||||
get "aria-expanded"() {
|
||||
return store.open
|
||||
},
|
||||
onClick: () => setStore({ open: true, loaded: true, restoreTrigger: true }),
|
||||
})}
|
||||
</div>
|
||||
<Show when={store.loaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
hideHeader
|
||||
title={language.t("dialog.model.select.title")}
|
||||
open={store.open}
|
||||
onOpenChange={(open) => setStore("open", open)}
|
||||
initialFocus={() => content}
|
||||
returnFocus={() => trigger?.querySelector("button") ?? undefined}
|
||||
onFinalFocus={(event) => {
|
||||
if (!store.restoreTrigger) event.preventDefault()
|
||||
}}
|
||||
onContentPresentChange={(present) => {
|
||||
if (present) return
|
||||
const action = store.action
|
||||
setStore("action", undefined)
|
||||
|
||||
if (action === "manage") {
|
||||
props.onManage()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "connect") {
|
||||
props.onConnect?.()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (action === "select") queueMicrotask(props.onClose)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={content}
|
||||
tabIndex={-1}
|
||||
data-slot="model-selector-drawer"
|
||||
class="flex min-h-0 flex-col gap-2 outline-none"
|
||||
>
|
||||
<div class="flex min-h-0 flex-col">
|
||||
<ModelList
|
||||
mobile
|
||||
open={store.open}
|
||||
model={props.model}
|
||||
provider={props.provider}
|
||||
onSelect={() => setStore({ open: false, action: "select", restoreTrigger: false })}
|
||||
/>
|
||||
</div>
|
||||
<div data-slot="model-selector-actions" class="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !h-10 !justify-start"
|
||||
icon="outline-sliders"
|
||||
onClick={() => setStore({ open: false, action: "manage", restoreTrigger: false })}
|
||||
>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
<Show when={props.onConnect}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full !h-10 !justify-start"
|
||||
icon="plus"
|
||||
onClick={() => setStore({ open: false, action: "connect", restoreTrigger: false })}
|
||||
>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={props.chatgptPlan}>
|
||||
<div class="flex min-h-10 items-center gap-2 border-t border-v2-border-border-muted px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
|
||||
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/settings/usage"
|
||||
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("dialog.model.chatgptManageUsage")}
|
||||
<Icon name="arrow-up-right" size="small" />
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</MobilePanelDrawer>
|
||||
</Suspense>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ import { createMemo, For, lazy, Show, Suspense, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import {
|
||||
DrawerContext,
|
||||
Panel,
|
||||
type PanelSidebar,
|
||||
type PanelTab,
|
||||
@@ -57,7 +54,7 @@ export function createMobileViews() {
|
||||
|
||||
export type MobileViews = ReturnType<typeof createMobileViews>
|
||||
|
||||
/** The narrow-screen view switcher: the conversation, each panel's view, and an overflow menu. */
|
||||
/** The narrow-screen view switcher: the conversation, each tab view, and a More drawer with the other views. */
|
||||
export function MobileViewTabs(props: {
|
||||
views: MobileViews
|
||||
region: Region
|
||||
@@ -65,34 +62,38 @@ export function MobileViewTabs(props: {
|
||||
session: MountedSession
|
||||
screen: SessionScreen
|
||||
sidebar: PanelSidebar
|
||||
bottom?: boolean
|
||||
onSelect: (key: string) => void
|
||||
}): JSX.Element {
|
||||
const language = useLanguage()
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
menu: boolean
|
||||
drawer: string | undefined
|
||||
last: string | undefined
|
||||
open: boolean
|
||||
loaded: boolean
|
||||
pending: string | undefined
|
||||
drawer: string | undefined
|
||||
}>({
|
||||
menu: false,
|
||||
drawer: undefined,
|
||||
// Keeps the last drawer's content mounted while it animates closed.
|
||||
last: undefined,
|
||||
open: false,
|
||||
loaded: false,
|
||||
pending: undefined,
|
||||
drawer: undefined,
|
||||
})
|
||||
|
||||
const drawer = createMemo(() => (store.last ? props.views.find(store.last) : undefined))
|
||||
const drawer = createMemo(() => (store.drawer ? props.views.find(store.drawer) : undefined))
|
||||
|
||||
const tab = createMemo(() =>
|
||||
props.current === "session" || props.views.tabs().some((entry) => entry.key === props.current)
|
||||
? props.current
|
||||
: "more",
|
||||
)
|
||||
|
||||
let trigger: HTMLButtonElement | undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
class="relative flex shrink-0 items-center before:pointer-events-none before:absolute before:inset-x-0 before:h-px before:bg-v2-border-border-base before:content-['']"
|
||||
classList={{ "before:top-0": props.bottom, "before:bottom-0": !props.bottom }}
|
||||
data-slot="session-mobile-view-navigation"
|
||||
>
|
||||
<Tabs value={props.current} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs value={tab()} variant="line" class="!h-auto min-w-0 flex-1" data-slot="session-mobile-view-tabs">
|
||||
<Tabs.List aria-label={language.t("session.view.select")} class="!h-9 !gap-0 !px-0 before:!hidden">
|
||||
<Tabs.Trigger
|
||||
value="session"
|
||||
@@ -114,73 +115,88 @@ export function MobileViewTabs(props: {
|
||||
</Tabs.Trigger>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.views.menu().length}>
|
||||
<Tabs.Trigger
|
||||
value="more"
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
class="min-w-0 flex-1"
|
||||
classes={{ button: "w-full justify-center" }}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={store.open}
|
||||
onClick={() => setStore({ open: true, loaded: true, drawer: undefined })}
|
||||
>
|
||||
{language.t("session.tab.more")}
|
||||
</Tabs.Trigger>
|
||||
</Show>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<Menu
|
||||
appearance="standard"
|
||||
modal={false}
|
||||
placement="bottom-end"
|
||||
gutter={4}
|
||||
open={store.menu}
|
||||
onOpenChange={(open) => setStore("menu", open)}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
ref={(element: HTMLButtonElement) => {
|
||||
trigger = element
|
||||
}}
|
||||
icon={<Icon name="menu" />}
|
||||
variant="ghost-muted"
|
||||
size="normal"
|
||||
class="mx-1.5 shrink-0"
|
||||
state={props.views.menu().some((entry) => entry.key === props.current) || store.menu ? "pressed" : undefined}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!store.pending) return
|
||||
event.preventDefault()
|
||||
setStore({ drawer: store.pending, last: store.pending, loaded: true, pending: undefined })
|
||||
}}
|
||||
>
|
||||
<For each={props.views.menu()}>
|
||||
{(entry) => (
|
||||
<Menu.Item
|
||||
onSelect={() => {
|
||||
if (entry.mobile.kind === "menu") return props.onSelect(entry.key)
|
||||
setStore({ pending: entry.key, menu: false })
|
||||
}}
|
||||
>
|
||||
{entry.mobile.title}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={store.loaded}>
|
||||
<Suspense>
|
||||
<MobilePanelDrawer
|
||||
title={drawer()?.mobile.title ?? ""}
|
||||
open={!!store.drawer}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setStore("drawer", undefined)
|
||||
}}
|
||||
title={drawer()?.mobile.title ?? language.t("common.moreOptions")}
|
||||
hideHeader
|
||||
open={store.open}
|
||||
onOpenChange={(open) => setStore("open", open)}
|
||||
returnFocus={() => trigger}
|
||||
>
|
||||
<Show when={drawer()} keyed>
|
||||
<Show
|
||||
when={drawer()}
|
||||
keyed
|
||||
fallback={
|
||||
<div
|
||||
data-slot="session-mobile-view-options"
|
||||
class="flex flex-col gap-0.5 rounded-[6px] bg-v2-background-bg-base p-0.5 shadow-[var(--v2-elevation-raised)] [[data-color-scheme=dark]_&]:bg-v2-background-bg-layer-01"
|
||||
>
|
||||
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "menu")}>
|
||||
{(entry) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)] aria-pressed:bg-[var(--v2-overlay-simple-overlay-pressed)]"
|
||||
aria-pressed={props.current === entry.key}
|
||||
onClick={() => {
|
||||
props.onSelect(entry.key)
|
||||
setStore("open", false)
|
||||
}}
|
||||
>
|
||||
<Show when={entry.mobile.icon}>
|
||||
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
</Show>
|
||||
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
|
||||
<Show when={props.current === entry.key}>
|
||||
<Icon name="check" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<For each={props.views.menu().filter((entry) => entry.mobile.kind === "drawer")}>
|
||||
{(entry) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-h-11 w-full items-center gap-2 rounded-[4px] py-1 ps-3 pe-2 text-start text-[13px] font-[440] leading-[var(--line-height-base)] text-v2-text-text-base hover:bg-[var(--v2-overlay-simple-overlay-hover)] focus-visible:outline-none focus-visible:bg-[var(--v2-overlay-simple-overlay-hover)]"
|
||||
onClick={() => setStore("drawer", entry.key)}
|
||||
>
|
||||
<Show when={entry.mobile.icon}>
|
||||
{(icon) => <Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
</Show>
|
||||
<span class="min-w-0 flex-1 truncate">{entry.mobile.title}</span>
|
||||
<Icon name="chevron-right" class="ms-1 shrink-0 text-v2-icon-icon-muted" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(entry) => (
|
||||
<DrawerContext.Provider value={{ close: () => setStore("drawer", undefined) }}>
|
||||
<MobilePanel
|
||||
entry={entry}
|
||||
view={props.session}
|
||||
screen={props.screen}
|
||||
sidebar={props.sidebar}
|
||||
visible={store.drawer === entry.key}
|
||||
open={() => props.region.openFor(entry.extension)}
|
||||
/>
|
||||
</DrawerContext.Provider>
|
||||
<MobilePanel
|
||||
entry={entry}
|
||||
view={props.session}
|
||||
screen={props.screen}
|
||||
sidebar={props.sidebar}
|
||||
visible={store.open}
|
||||
open={() => props.region.openFor(entry.extension)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</MobilePanelDrawer>
|
||||
|
||||
@@ -490,6 +490,7 @@ export const dict = {
|
||||
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.more": "More...",
|
||||
"session.view.select": "Session view",
|
||||
"session.tab.add": "Add tab",
|
||||
"session.tab.unknown": "Unknown Session",
|
||||
|
||||
@@ -4,7 +4,18 @@ import { pairingLink } from "./pairing"
|
||||
describe("pairing link", () => {
|
||||
test("reads the server address and code from opencode pair links", () => {
|
||||
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
|
||||
url: "http://192.168.1.2:49374",
|
||||
urls: ["http://192.168.1.2:49374"],
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
|
||||
test("reads every server address from opencode pair QR codes", () => {
|
||||
expect(
|
||||
pairingLink(
|
||||
JSON.stringify({ code: "abc_DEF-123", urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"] }),
|
||||
),
|
||||
).toEqual({
|
||||
urls: ["http://192.168.1.2:49374", "http://100.64.0.3:49374"],
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
@@ -16,4 +27,12 @@ describe("pairing link", () => {
|
||||
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
|
||||
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("rejects malformed QR payloads", () => {
|
||||
expect(pairingLink(JSON.stringify({ code: "abc", urls: [] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ code: "a/b", urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ code: "abc", urls: ["opencode-ios://host"] }))).toBeUndefined()
|
||||
expect(pairingLink(JSON.stringify({ urls: ["http://192.168.1.2:49374"] }))).toBeUndefined()
|
||||
expect(pairingLink("{not json")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
@@ -15,26 +16,48 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
|
||||
const CODE = /^[A-Za-z0-9_-]+$/
|
||||
|
||||
const decodePayload = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Struct({ code: Schema.String, urls: Schema.Array(Schema.String) })),
|
||||
)
|
||||
|
||||
// `opencode pair` prints links carrying a single-use code that the server exchanges for a session token.
|
||||
// Its QR code carries the same code with every reachable server address as {"code","urls"} JSON.
|
||||
export function pairingLink(value: string) {
|
||||
const url = URL.parse(value.trim())
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (trimmed.startsWith("{")) {
|
||||
const payload = Option.getOrUndefined(decodePayload(trimmed))
|
||||
|
||||
if (!payload || !CODE.test(payload.code)) return
|
||||
const urls = [...new Set(payload.urls)].map(serverAddress)
|
||||
|
||||
if (urls.length === 0 || urls.some((url) => url === undefined)) return
|
||||
|
||||
return { urls: urls.filter((url) => url !== undefined), code: payload.code }
|
||||
}
|
||||
|
||||
const url = URL.parse(trimmed)
|
||||
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
||||
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
|
||||
const code = /^\/auth\/connect\/([^/]+)$/.exec(url.pathname)?.[1]
|
||||
const address = serverAddress(url.origin)
|
||||
|
||||
if (!code || !address) return
|
||||
if (!code || !CODE.test(code) || !address) return
|
||||
|
||||
return { url: address, code }
|
||||
return { urls: [address], code }
|
||||
}
|
||||
|
||||
export type Pairing = { readonly url: string; readonly password: string }
|
||||
|
||||
export function redeemPairingLink(link: { url: string; code: string }) {
|
||||
return OpenCode.make({ baseUrl: link.url })
|
||||
.server.connect({ code: link.code })
|
||||
.then(
|
||||
(session): Pairing => ({ url: link.url, password: session.token }),
|
||||
() => undefined,
|
||||
)
|
||||
// Every address may reach the same server, but the code is single-use, so at most one attempt succeeds.
|
||||
export function redeemPairingLink(link: { urls: ReadonlyArray<string>; code: string }) {
|
||||
return Promise.any(
|
||||
link.urls.map((url) =>
|
||||
OpenCode.make({ baseUrl: url })
|
||||
.server.connect({ code: link.code }, { signal: AbortSignal.timeout(5_000) })
|
||||
.then((session): Pairing => ({ url, password: session.token })),
|
||||
),
|
||||
).catch(() => undefined)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export function ConnectServerScreen(props: { url?: string } = {}) {
|
||||
}
|
||||
|
||||
// Keep the token in the form so a failed connection check can retry without the spent code.
|
||||
setState({ url: link.url, password: redeemed.password })
|
||||
setState({ url: redeemed.url, password: redeemed.password })
|
||||
}
|
||||
|
||||
const url = serverAddress(state.url)
|
||||
|
||||
@@ -186,7 +186,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-down-to-line" />}
|
||||
icon={<Icon name="outline-undo" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.undo")}
|
||||
onClick={() => props.queue.undo(props.id)}
|
||||
|
||||
@@ -160,7 +160,7 @@ function PendingSessionState(props: { sessionID: string }) {
|
||||
|
||||
function SessionStatePanel(props: ParentProps) {
|
||||
return (
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { Slot, type BackgroundTask, type MountedSession, type SessionScreen } from "@opencode/gui-extensions/sdk"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
import { SessionPanelFrame } from "@/session/session-frame"
|
||||
@@ -64,7 +65,9 @@ function SessionScreenContent(props: {
|
||||
const session = props.session
|
||||
const host = useExtensionHost()
|
||||
const attachment = useExtensionAttachment()
|
||||
const settings = useSettings()
|
||||
const isDesktop = session.isDesktop
|
||||
const bottomMobileTabs = () => settings.general.mobileTitlebarPosition() === "bottom"
|
||||
const sidebar = createPanelSidebar()
|
||||
|
||||
const region = createRegion({
|
||||
@@ -274,6 +277,23 @@ function SessionScreenContent(props: {
|
||||
() => conversationVisible() && messagesReady(),
|
||||
)
|
||||
|
||||
const mobileTabs = () => (
|
||||
<Show when={session.identity.sessionKey()} keyed>
|
||||
{(_key) => (
|
||||
<MobileViewTabs
|
||||
bottom={bottomMobileTabs()}
|
||||
screen={props.screen}
|
||||
views={mobile}
|
||||
region={region}
|
||||
current={mobileView()}
|
||||
session={props.view()}
|
||||
sidebar={sidebar}
|
||||
onSelect={selectMobile}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
|
||||
const sessionPanelContent = () => (
|
||||
<>
|
||||
<ComposerDropzone
|
||||
@@ -281,21 +301,7 @@ function SessionScreenContent(props: {
|
||||
input={composer.drop.input()}
|
||||
identity={session.layout.tabKey}
|
||||
/>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id}>
|
||||
<Show when={session.identity.sessionKey()} keyed>
|
||||
{(_key) => (
|
||||
<MobileViewTabs
|
||||
screen={props.screen}
|
||||
views={mobile}
|
||||
region={region}
|
||||
current={mobileView()}
|
||||
session={props.view()}
|
||||
sidebar={sidebar}
|
||||
onSelect={selectMobile}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && !bottomMobileTabs()}>{mobileTabs()}</Show>
|
||||
{/* Surface query errors without suspending session metadata while messages load. */}
|
||||
<Show when={timeline.resource.error}>
|
||||
{(error) => {
|
||||
@@ -354,12 +360,13 @@ function SessionScreenContent(props: {
|
||||
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!isDesktop() && !!session.identity.params.id && bottomMobileTabs()}>{mobileTabs()}</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
|
||||
{/* Keep the control outside panel animations; a side dock's 52px header includes a 1px divider. */}
|
||||
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
|
||||
|
||||
@@ -4,7 +4,10 @@ export function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
class="relative flex size-full flex-col"
|
||||
classList={{ "px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]": props.padded }}
|
||||
classList={{
|
||||
"px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]":
|
||||
props.padded,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
.settings-screen {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: calc(100% - 16px);
|
||||
width: calc(100% - 2 * var(--shell-inline-inset, 8px));
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin-inline: 8px;
|
||||
margin-inline: var(--shell-inline-inset, 8px);
|
||||
margin-block: var(--shell-top-inset, 8px) var(--shell-bottom-inset, 8px);
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
@@ -31,7 +31,6 @@
|
||||
@media (max-width: 767px) {
|
||||
.settings-screen {
|
||||
--settings-mobile-inner-inset: 8px;
|
||||
margin-block-start: var(--settings-top-inset, var(--shell-top-inset, 8px));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ export function MobileDrawer(
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onContentPresentChange?: (present: boolean) => void
|
||||
initialFocus?: () => HTMLElement | undefined
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
onFinalFocus?: (event: Event) => void
|
||||
closeOnOutsideFocus?: boolean
|
||||
}>,
|
||||
) {
|
||||
@@ -18,7 +20,9 @@ export function MobileDrawer(
|
||||
onOpenChange={props.onOpenChange}
|
||||
onContentPresentChange={props.onContentPresentChange}
|
||||
side="bottom"
|
||||
initialFocusEl={props.initialFocus?.()}
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
onFinalFocus={props.onFinalFocus}
|
||||
closeOnOutsideFocus={props.closeOnOutsideFocus}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -1,43 +1,98 @@
|
||||
import type { ParentProps } from "solid-js"
|
||||
import { createEffect, on, Show, type JSX, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DrawerContext } from "@opencode/gui-extensions/sdk"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
|
||||
import "./mobile-panel-drawer.css"
|
||||
|
||||
type DrawerView = { title: string; content: JSX.Element; trigger: HTMLElement }
|
||||
|
||||
export function MobilePanelDrawer(
|
||||
props: ParentProps<{
|
||||
title: string
|
||||
hideHeader?: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onContentPresentChange?: (present: boolean) => void
|
||||
initialFocus?: () => HTMLElement | undefined
|
||||
returnFocus?: () => HTMLElement | undefined
|
||||
onFinalFocus?: (event: Event) => void
|
||||
}>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore<{ view?: DrawerView }>({})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (open) setStore("view", undefined)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const navigation = {
|
||||
close: () => props.onOpenChange(false),
|
||||
open: (view: DrawerView) => setStore("view", view),
|
||||
back: () => {
|
||||
const trigger = store.view?.trigger
|
||||
setStore("view", undefined)
|
||||
trigger?.focus()
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileDrawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
returnFocus={props.returnFocus}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
<MobileDrawerContent>
|
||||
<div data-slot="mobile-panel" data-corvu-no-drag>
|
||||
<div data-slot="mobile-panel-header">
|
||||
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
|
||||
<MobileDrawerClose
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
data-slot="mobile-panel-close"
|
||||
aria-label={language.t("common.close")}
|
||||
<DrawerContext.Provider value={navigation}>
|
||||
<MobileDrawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
onContentPresentChange={props.onContentPresentChange}
|
||||
initialFocus={props.initialFocus}
|
||||
returnFocus={props.returnFocus}
|
||||
onFinalFocus={props.onFinalFocus}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
<MobileDrawerContent>
|
||||
<div data-slot="mobile-panel" data-corvu-no-drag>
|
||||
<Show
|
||||
when={!props.hideHeader}
|
||||
fallback={<MobileDrawerLabel class="sr-only">{store.view?.title ?? props.title}</MobileDrawerLabel>}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</MobileDrawerClose>
|
||||
<div data-slot="mobile-panel-header">
|
||||
<MobileDrawerLabel>{store.view?.title ?? props.title}</MobileDrawerLabel>
|
||||
<MobileDrawerClose
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
data-slot="mobile-panel-close"
|
||||
aria-label={language.t("common.close")}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</MobileDrawerClose>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="mobile-panel-content">
|
||||
<div hidden={!!store.view}>{props.children}</div>
|
||||
<Show when={store.view}>
|
||||
{(view) => (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="arrow-left"
|
||||
class="mb-2 !justify-start"
|
||||
aria-label={language.t("common.goBack")}
|
||||
onClick={navigation.back}
|
||||
>
|
||||
{props.title}
|
||||
</Button>
|
||||
{view().content}
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="mobile-panel-content">{props.children}</div>
|
||||
</div>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
</DrawerContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function AppRoutes() {
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<div class="flex min-h-0 flex-1 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -31,9 +31,12 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
// Native Windows chrome supplies the gap; retain paint clearance for the panels' outer outlines.
|
||||
// Mobile panels only need clearance for their outer border.
|
||||
"--shell-inline-inset": mobile() ? "1px" : "8px",
|
||||
// A bottom mobile titlebar leaves main's top edge to the safe area. Native Windows chrome supplies the gap;
|
||||
// retain outer-outline clearance.
|
||||
"--shell-top-inset": bottomTitlebar()
|
||||
? "max(0px, calc(8px - env(safe-area-inset-top, 0px)))"
|
||||
? "0px"
|
||||
: platform.platform === "desktop" && platform.os === "windows"
|
||||
? "1px"
|
||||
: "8px",
|
||||
@@ -76,7 +79,6 @@ export default function Layout(props: ParentProps) {
|
||||
"--settings-bottom-inset": bottomTitlebar()
|
||||
? "40px"
|
||||
: "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))",
|
||||
"--settings-top-inset": mobile() && !bottomTitlebar() ? "0px" : "var(--shell-top-inset, 8px)",
|
||||
}}
|
||||
>
|
||||
<ExtensionServerCover>
|
||||
|
||||
@@ -49,6 +49,10 @@ const macTrafficLightsBaseWidth = 68
|
||||
|
||||
const macTrafficLightsTopClearance = 28
|
||||
|
||||
// iOS blurs page content just below the status bar in Home Screen web apps, so with a top safe area the phone
|
||||
// titlebar row stays 16px clear of it, as ChatGPT's phone header does. Without one, 8px matches the content gap below.
|
||||
const mobileTopClearance = "max(8px, min(16px, env(safe-area-inset-top, 0px) * 1000))"
|
||||
|
||||
export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
const platform = usePlatform()
|
||||
const command = useCommand()
|
||||
@@ -152,15 +156,13 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
? bottom()
|
||||
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
|
||||
: mobileTop()
|
||||
? "calc(28px + 16px + env(safe-area-inset-top, 0px))"
|
||||
? `calc(28px + ${mobileTopClearance} + env(safe-area-inset-top, 0px))`
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: undefined,
|
||||
// iOS blurs page content just below the status bar in Home Screen web apps, so keep the phone titlebar row
|
||||
// 16px clear of the safe area, as ChatGPT's phone header does.
|
||||
"padding-top": bottom()
|
||||
? "0px"
|
||||
: mobileTop()
|
||||
? "calc(16px + env(safe-area-inset-top, 0px))"
|
||||
? `calc(${mobileTopClearance} + env(safe-area-inset-top, 0px))`
|
||||
: "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
|
||||
"min-height": minHeight(),
|
||||
|
||||
@@ -389,6 +389,8 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
<div
|
||||
class="directory-picker-browser"
|
||||
ref={container}
|
||||
// The modal's scroll lock sees only the shadow host, not the tree's inner scroller.
|
||||
on:touchmove={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => {
|
||||
const scroller = tree
|
||||
?.getFileTreeContainer()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -124,15 +124,17 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
const load = (client: OpenCodeClient, cwd: string) =>
|
||||
read(client, cwd).pipe(
|
||||
// Providers may still be discovering models after startup.
|
||||
Effect.retry({
|
||||
while: (error) => error._tag === "ACPCatalogNotReadyError",
|
||||
schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" })),
|
||||
}),
|
||||
Effect.withSpan("cli.acp.catalog.load"),
|
||||
const poll = Schedule.spaced("25 millis").pipe(Schedule.upTo({ duration: "5 seconds" }))
|
||||
|
||||
// A cold Location lists no plugins until activation finishes, and providers may still discover models after that.
|
||||
const load = Effect.fn("cli.acp.catalog.load")(function* (client: OpenCodeClient, cwd: string) {
|
||||
yield* client.plugin
|
||||
.list({ location: { directory: cwd } })
|
||||
.pipe(Effect.repeat({ until: (plugins) => plugins.data.length > 0, schedule: poll }), Effect.ignore)
|
||||
return yield* read(client, cwd).pipe(
|
||||
Effect.retry({ while: (error) => error._tag === "ACPCatalogNotReadyError", schedule: poll }),
|
||||
)
|
||||
})
|
||||
|
||||
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
const location = { directory: cwd }
|
||||
|
||||
@@ -28,10 +28,10 @@ export function configOptions(catalog: Catalog, selection: Selection): SessionCo
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${model.providerID}/${model.id}`,
|
||||
currentValue: advertisedModel(model),
|
||||
options: catalog.models
|
||||
.toSorted((a, b) => Order.String(a.providerID, b.providerID) || a.name.localeCompare(b.name))
|
||||
.map((item) => ({ value: `${item.providerID}/${item.id}`, name: `${item.providerID}/${item.name}` })),
|
||||
.map((item) => ({ value: advertisedModel(item), name: `${item.providerID}/${item.name}` })),
|
||||
},
|
||||
...(variants.length > 0
|
||||
? [
|
||||
@@ -101,24 +101,18 @@ export const resolveChange = Effect.fnUntraced(function* (
|
||||
})
|
||||
|
||||
export function parseModelSelection(value: string, models: ReadonlyArray<Model.Info>): Model.Ref {
|
||||
const providerID = models
|
||||
.map((model) => model.providerID)
|
||||
.toSorted()
|
||||
.find((id) => value.startsWith(`${id}/`))
|
||||
if (!providerID) {
|
||||
const separator = value.indexOf("/")
|
||||
if (separator === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
|
||||
return { providerID: Provider.ID.make(value.slice(0, separator)), id: Model.ID.make(value.slice(separator + 1)) }
|
||||
}
|
||||
const id = Model.ID.make(value.slice(providerID.length + 1))
|
||||
if (findModel(models, { providerID, id })) return { providerID, id }
|
||||
const separator = id.lastIndexOf("/")
|
||||
const baseID = Model.ID.make(separator === -1 ? id : id.slice(0, separator))
|
||||
const variant = separator === -1 ? undefined : id.slice(separator + 1)
|
||||
const model = findModel(models, { providerID, id: baseID })
|
||||
if (model && variant && model.variants.some((item) => item.id === variant))
|
||||
return { providerID, id: baseID, variant: Model.VariantID.make(variant) }
|
||||
return { providerID, id }
|
||||
const exact = models.find((model) => advertisedModel(model) === value)
|
||||
if (exact) return { providerID: exact.providerID, id: exact.id }
|
||||
const separator = value.lastIndexOf("/")
|
||||
const variant = Model.VariantID.make(value.slice(separator + 1))
|
||||
const base = models.find(
|
||||
(model) =>
|
||||
advertisedModel(model) === value.slice(0, separator) && model.variants.some((item) => item.id === variant),
|
||||
)
|
||||
if (base) return { providerID: base.providerID, id: base.id, variant }
|
||||
const providerEnd = value.indexOf("/")
|
||||
if (providerEnd === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
|
||||
return { providerID: Provider.ID.make(value.slice(0, providerEnd)), id: Model.ID.make(value.slice(providerEnd + 1)) }
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: string, current: Model.Ref) {
|
||||
@@ -137,6 +131,10 @@ const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: strin
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
|
||||
function advertisedModel(model: { readonly providerID: string; readonly id: string }) {
|
||||
return `${model.providerID}/${model.id}`
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
|
||||
if (variants.includes(variant)) return variant
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isAbsolute, join, resolve } from "node:path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
@@ -13,6 +14,7 @@ const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
|
||||
|
||||
// Permission resources treat `*` and `?` as wildcards.
|
||||
export const parse = Effect.fnUntraced(function* (cwd: string, directories: readonly string[] = []) {
|
||||
yield* parseCwd(cwd)
|
||||
const invalid = directories.find((directory) => !isAbsolute(directory) || /[*?]/.test(directory))
|
||||
if (invalid !== undefined) return yield* new ACPError.InvalidAdditionalDirectoryError({ directory: invalid })
|
||||
const root = FSUtil.resolve(cwd)
|
||||
@@ -21,6 +23,12 @@ export const parse = Effect.fnUntraced(function* (cwd: string, directories: read
|
||||
)
|
||||
})
|
||||
|
||||
export const parseCwd = Effect.fnUntraced(function* (cwd: string) {
|
||||
if (!isAbsolute(cwd))
|
||||
return yield* new ACPError.InvalidRequestError({ message: `cwd must be an absolute path: ${cwd}`, field: "cwd" })
|
||||
return AbsolutePath.make(cwd)
|
||||
})
|
||||
|
||||
export function grant(directories: readonly string[]) {
|
||||
if (directories.length === 0) return {}
|
||||
return { permissions: rules(directories), metadata: { [key]: [...directories] } }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { PermissionOption } from "@agentclientprotocol/sdk"
|
||||
import type { PermissionOption, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { applyPatch } from "diff"
|
||||
import { applyPatch, parsePatch, reversePatch, structuredPatch, type StructuredPatch } from "diff"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
pendingToolCall,
|
||||
stringValue,
|
||||
toLocations,
|
||||
type DiffSource,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
@@ -60,6 +61,7 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
sessionId: input.clientSessionID,
|
||||
toolCall: {
|
||||
...toolCall,
|
||||
name: input.tool?.name,
|
||||
rawInput: input.tool ? toolCall.rawInput : undefined,
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
@@ -78,6 +80,45 @@ export function respond(input: Input, decision: Permission.Reply) {
|
||||
)
|
||||
}
|
||||
|
||||
export const withCompletedDiffs = Effect.fnUntraced(function* (
|
||||
update: SessionUpdate,
|
||||
source: DiffSource | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
if (!source || update.sessionUpdate !== "tool_call_update") return update
|
||||
const hunks = canonicalName(source.toolName) === "patch" ? patchHunks(source.input) : []
|
||||
const diffs = yield* Effect.forEach(
|
||||
Option.getOrElse(decodeFiles(source.metadata?.files), () => []),
|
||||
(file) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(file.file, cwd)
|
||||
const newText = file.status === "deleted" ? "" : yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
if (file.status === "added") return [diff(path, null, newText)]
|
||||
const recorded = parsePatch(file.patch)[0]
|
||||
if (!recorded) return []
|
||||
const oldText = yield* Effect.try(() => applyPatch(newText, reversePatch(recorded)))
|
||||
if (oldText !== false) return [diff(path, oldText, newText)]
|
||||
// Core trims indentation from patch-tool diffs, so rebuild those from its hunks and keep them if the ranges agree.
|
||||
const hunk = hunks.find(
|
||||
(item) => item.type === "update" && absolutePath(item.movePath ?? item.path, cwd) === path,
|
||||
)
|
||||
if (hunk?.type !== "update" || hunk.chunks.some((chunk) => !chunk.oldLines.length || !chunk.newLines.length))
|
||||
return []
|
||||
const chunks = hunk.chunks.map((chunk) => ({ ...chunk, oldLines: chunk.newLines, newLines: chunk.oldLines }))
|
||||
const rebuilt = (yield* Effect.try(() => Patch.derive(hunk.path, chunks, newText))).content
|
||||
return ranges(structuredPatch(path, path, rebuilt, newText)) === ranges(recorded)
|
||||
? [diff(path, rebuilt, newText)]
|
||||
: []
|
||||
}).pipe(Effect.orElseSucceed((): Preview[] => [])),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((items) => items.flat()))
|
||||
return diffs.length === 0 ? update : { ...update, content: [...(update.content ?? []), ...diffs] }
|
||||
})
|
||||
|
||||
function ranges(patch: StructuredPatch) {
|
||||
return patch.hunks.map((hunk) => `${hunk.oldStart},${hunk.oldLines},${hunk.newStart},${hunk.newLines}`).join(" ")
|
||||
}
|
||||
|
||||
// Core trims the patch tool's diffs for display, which breaks `applyPatch`, so its previews come from its own hunks.
|
||||
const permissionPreviews = Effect.fnUntraced(function* (
|
||||
toolName: string,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ACPClient } from "./client"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks } from "./content"
|
||||
import { ACPPermission } from "./permission"
|
||||
import type { Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate } from "./tool"
|
||||
@@ -29,7 +30,10 @@ export function history(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(
|
||||
updates(message, attached.cwd, capabilities),
|
||||
(update) => connection.sessionUpdate({ sessionId: attached.id, update }),
|
||||
(update) =>
|
||||
ACPPermission.withCompletedDiffs(update, completedSource(message, update), attached.cwd).pipe(
|
||||
Effect.flatMap((enriched) => connection.sessionUpdate({ sessionId: attached.id, update: enriched })),
|
||||
),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
@@ -124,4 +128,11 @@ export function updates(message: SessionMessage.Info, cwd: string, capabilities:
|
||||
})
|
||||
}
|
||||
|
||||
function completedSource(message: SessionMessage.Info, update: SessionUpdate) {
|
||||
if (message.type !== "assistant" || update.sessionUpdate !== "tool_call_update") return undefined
|
||||
const part = message.content.find((item) => item.type === "tool" && item.id === update.toolCallId)
|
||||
if (part?.type !== "tool" || part.state.status !== "completed") return undefined
|
||||
return { toolName: part.name, input: part.state.input, metadata: part.state.metadata }
|
||||
}
|
||||
|
||||
export * as ACPReplay from "./replay"
|
||||
@@ -109,6 +109,7 @@ export function make(input: {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
id: AuthMethodID,
|
||||
...(params.clientCapabilities?.auth?.terminal ? { type: "terminal" as const, args: ["--login"] } : {}),
|
||||
}
|
||||
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
|
||||
authMethod._meta = {
|
||||
@@ -164,7 +165,9 @@ export function make(input: {
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
const page = yield* input.client.session
|
||||
.list({
|
||||
...(params.cwd ? { directory: AbsolutePath.make(params.cwd) } : {}),
|
||||
...(params.cwd !== undefined && params.cwd !== null
|
||||
? { directory: yield* ACPDirectories.parseCwd(params.cwd) }
|
||||
: {}),
|
||||
order: "desc",
|
||||
limit: 100,
|
||||
...(params.cursor ? { cursor: Schema.decodeSync(SessionsCursor)(params.cursor) } : {}),
|
||||
@@ -188,6 +191,7 @@ export function make(input: {
|
||||
}
|
||||
}),
|
||||
deleteSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.cancel({ sessionId: params.sessionId })
|
||||
yield* ACPClient.decodeSessionID(params.sessionId).pipe(
|
||||
Effect.flatMap((sessionID) => input.client.session.remove({ sessionID })),
|
||||
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
|
||||
@@ -212,8 +216,8 @@ export function make(input: {
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const mcpServers = yield* supportedMcpServers(params.mcpServers)
|
||||
const sessionID = yield* ACPClient.decodeSessionID(params.sessionId)
|
||||
const forked = yield* input.client.session.fork({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
const forked = yield* input.client.session.fork({ sessionID: session.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
// Forks inherit the source's grants; replace them with this request's.
|
||||
yield* ACPDirectories.activate(input.client, forked, directories)
|
||||
const attachment = yield* input.sessions.attach(forked, forked.location.directory, mcpServers)
|
||||
|
||||
@@ -7,6 +7,12 @@ import { Result } from "effect"
|
||||
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
export type DiffSource = {
|
||||
readonly toolName: string
|
||||
readonly input: ToolInput
|
||||
readonly metadata?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
switch (canonicalName(toolName)) {
|
||||
case "shell":
|
||||
@@ -66,6 +72,7 @@ export function pendingToolCall(input: {
|
||||
}): ToolCall {
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
name: input.toolName,
|
||||
title: toolTitle(input.toolName, input.state.input, input.state.title),
|
||||
kind: toToolKind(input.toolName),
|
||||
status: "pending",
|
||||
@@ -106,18 +113,11 @@ export function completedToolUpdate(input: {
|
||||
read === undefined
|
||||
? normalized.filter((part) => !images.includes(part))
|
||||
: [{ type: "content" as const, content: { type: "text" as const, text: read } }]
|
||||
const oldText = stringValue(input.input.oldString)
|
||||
const newText = stringValue(input.input.newString)
|
||||
const path = filePath(input.input)
|
||||
const diff: ToolCallContent[] =
|
||||
oldText === undefined || newText === undefined || path === undefined
|
||||
? []
|
||||
: [{ type: "diff", path: absolutePath(path, input.cwd), oldText, newText }]
|
||||
return {
|
||||
toolCallId: input.toolCallId,
|
||||
status: "completed",
|
||||
locations: toLocations(input.toolName, input.input, input.cwd),
|
||||
content: [...primary, ...diff, ...images],
|
||||
content: [...primary, ...images],
|
||||
rawOutput: {
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,14 @@ import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import { ACPError } from "./error"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
import {
|
||||
completedToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
type DiffSource,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
const RetryMeta = "opencode/retry"
|
||||
|
||||
@@ -56,8 +63,8 @@ type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
type CreatedEvent = Extract<OpenCodeEvent, { type: "session.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ACPChild.Update }
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate; readonly diff?: DiffSource }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ACPChild.Update; readonly diff?: DiffSource }
|
||||
| {
|
||||
readonly _tag: "PermissionAsk"
|
||||
readonly event: PermissionEvent
|
||||
@@ -374,7 +381,10 @@ function sessionEvent(
|
||||
content: event.data.content,
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}).map((output) => ({
|
||||
...output,
|
||||
diff: { toolName: tool.name, input: tool.input, metadata: event.data.metadata },
|
||||
})),
|
||||
}
|
||||
}
|
||||
case "session.tool.failed": {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Stream,
|
||||
} from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
import { findModel, type ACPCatalog } from "./catalog"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
@@ -135,17 +135,22 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
switch (output._tag) {
|
||||
case "SessionUpdate":
|
||||
if (turn.background) return Effect.void
|
||||
return input.connection.sessionUpdate({ sessionId: turn.ctx.sessionID, update: output.update })
|
||||
return ACPPermission.withCompletedDiffs(output.update, output.diff, turn.ctx.cwd).pipe(
|
||||
Effect.flatMap((update) => input.connection.sessionUpdate({ sessionId: turn.ctx.sessionID, update })),
|
||||
)
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
if (output.update.type !== "update")
|
||||
return yield* input.connection.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
const update = yield* ACPPermission.withCompletedDiffs(output.update.update, output.diff, turn.ctx.cwd)
|
||||
return yield* input.connection.extNotification(ACPChild.UpdateMethod, { ...output.update, update })
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.void
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
case "PermissionAsk": {
|
||||
const permission = {
|
||||
client: input.client,
|
||||
@@ -312,10 +317,12 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const failure = ACPTranslate.failure(current)
|
||||
const terminal = Exit.isSuccess(exit) ? exit.value : "interrupted"
|
||||
const failure = terminal === "interrupted" ? undefined : ACPTranslate.failure(current)
|
||||
if (failure) return yield* failure
|
||||
yield* sendUsageUpdate(attached, current)
|
||||
return ACPTranslate.response(current, attached.id, Exit.isSuccess(exit) ? exit.value : "interrupted")
|
||||
const cancelledWhileSettling = Exit.isFailure(yield* Effect.exit(Effect.interruptible(Effect.void)))
|
||||
return ACPTranslate.response(current, attached.id, cancelledWhileSettling ? "interrupted" : terminal)
|
||||
})
|
||||
|
||||
const sendUsageUpdate = Effect.fn("cli.acp.turn.usage")(
|
||||
@@ -323,8 +330,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const used = state.usage ? TokenUsage.total(state.usage.last) : 0
|
||||
if (!used) return
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
const model = findModel(catalog.models, currentModel(catalog, yield* Ref.get(attached.selection)))
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* input.client.session.get({ sessionID: attached.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
yield* input.connection.sessionUpdate({
|
||||
@@ -342,8 +348,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
),
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
|
||||
// Forked uninterruptible: interruption lands only in `execute` and the last check in `settle`, so a response follows.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (params: PromptRequest) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const prompt = yield* ACPPrompt.prepare(yield* input.catalog.get(attached.cwd), params.prompt)
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const exit = yield* Effect.acquireUseRelease(
|
||||
@@ -365,21 +373,18 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
|
||||
return {
|
||||
prompt: Effect.fnUntraced(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const prompt = yield* ACPPrompt.prepare(catalog, params.prompt)
|
||||
// Synchronous, so concurrent prompts for one session cannot both register.
|
||||
// Synchronous and before setup, so concurrent prompts cannot both register and an early cancel still applies.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
if (FiberMap.hasUnsafe(turns, params.sessionId)) {
|
||||
return Effect.fail(
|
||||
new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${attached.id}`,
|
||||
safeMessage: `Session already has an active ACP prompt: ${params.sessionId}`,
|
||||
service: "session",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
const forked = Effect.runForkWith(fiber.context)(run(params), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, params.sessionId, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
// A `$/cancel_request` for this prompt cancels its turn like `session/cancel`, rather than failing the request.
|
||||
|
||||
@@ -96,7 +96,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||
Spec.make("acp", {
|
||||
description: "Start an Agent Client Protocol server",
|
||||
params: {
|
||||
login: Flag.boolean("login").pipe(
|
||||
Flag.withDescription("Run auth login instead of starting the server"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("api", {
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { MessageTooLargeError, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
@@ -12,7 +12,17 @@ import { Standalone } from "../../services/standalone"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.acp,
|
||||
Effect.fn("cli.acp")(function* () {
|
||||
Effect.fn("cli.acp")(function* (input) {
|
||||
if (input.login) {
|
||||
const login = yield* Effect.promise(() => import("./auth/login"))
|
||||
return yield* login.default({
|
||||
target: Option.none(),
|
||||
method: Option.none(),
|
||||
answer: [],
|
||||
server: Option.none(),
|
||||
standalone: false,
|
||||
})
|
||||
}
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
|
||||
|
||||
@@ -21,17 +21,21 @@ export default Runtime.handler(
|
||||
: (yield* Effect.tryPromise(() => client.server.info())).urls
|
||||
const pairing = yield* Effect.tryPromise(() => client.server.pair())
|
||||
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
|
||||
// Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses.
|
||||
const remote = urls.filter((url) => !isLoopback(new URL(url).hostname))
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
|
||||
"",
|
||||
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
|
||||
...(links[0]
|
||||
...(remote.length
|
||||
? [
|
||||
"",
|
||||
// uqr separates rows with "\n" on every platform, so splitting on EOL ("\r\n" on Windows) indents only the first row.
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
renderUnicodeCompact(JSON.stringify({ code: pairing.code, urls: remote }), {
|
||||
border: 2,
|
||||
})
|
||||
.split("\n")
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
@@ -56,3 +60,9 @@ export default Runtime.handler(
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function isLoopback(hostname: string) {
|
||||
return (
|
||||
hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]"
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { currentValue } from "./select-options"
|
||||
import { rpcError, secondModel, startSession } from "./wire-fixture"
|
||||
import { rpcError, secondModel, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog and config options over the wire", () => {
|
||||
test("switches model, effort, and mode against the warm catalog", async () => {
|
||||
await using acp = await startSession()
|
||||
const sessionId = acp.sessionId
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models.push({ ...secondModel, providerID: "test/sub" })
|
||||
await acp.initialize()
|
||||
const sessionId = (await acp.newSession()).sessionId
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId, configId, value })
|
||||
|
||||
@@ -32,5 +34,38 @@ describe("acp catalog and config options over the wire", () => {
|
||||
code: -32602,
|
||||
data: { modelId: "test/missing-model" },
|
||||
})
|
||||
const overlap = "test/sub/second-model"
|
||||
expect(currentValue(await set("model", overlap), "model")).toBe(overlap)
|
||||
expect(currentValue(await set("model", `${overlap}/low`), "effort")).toBe("low")
|
||||
})
|
||||
|
||||
test("answers the first session after a cold location activates its plugins (#52729, #52472)", async () => {
|
||||
await using acp = await startWire()
|
||||
const plugins = acp.server.catalog.plugins.splice(0)
|
||||
acp.server.catalog.models.splice(1)
|
||||
await acp.initialize()
|
||||
const activated = Bun.sleep(200).then(() => {
|
||||
acp.server.catalog.plugins.push(...plugins)
|
||||
acp.server.catalog.models.push(secondModel)
|
||||
})
|
||||
|
||||
const session = await acp.newSession()
|
||||
const selected = await acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await activated
|
||||
|
||||
expect(session.configOptions).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "model",
|
||||
options: [
|
||||
{ value: "test/second-model", name: "test/Second Model" },
|
||||
{ value: "test/test-model", name: "test/Test Model" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(currentValue(selected, "model")).toBe("test/second-model")
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, CreateElicitationResponse, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
delivered,
|
||||
fileDiff,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
permissionAsked,
|
||||
@@ -284,6 +284,8 @@ describe("acp edit previews over the wire", () => {
|
||||
[{ path: file("folder") }],
|
||||
[{ path: file("unpatched.ts") }],
|
||||
])
|
||||
expect(acp.permissions[0]?.toolCall.name).toBe("edit")
|
||||
expect(acp.permissions[4]?.toolCall).not.toHaveProperty("name")
|
||||
expect(decisions(acp)).toHaveLength(8)
|
||||
})
|
||||
})
|
||||
@@ -304,6 +306,3 @@ function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
|
||||
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
|
||||
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { pathToFileURL } from "node:url"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import {
|
||||
delivered,
|
||||
fileDiff,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
@@ -32,10 +33,25 @@ import {
|
||||
type WireOptions,
|
||||
} from "./wire-fixture"
|
||||
|
||||
// A "hold" prompt is admitted and starts streaming, then only finishes when interrupted.
|
||||
const lost = { type: "provider.transport", message: "stream connection lost" }
|
||||
|
||||
// A "hold" prompt is admitted, streams, and fails a step, then waits on its retry until interrupted.
|
||||
const held = {
|
||||
onPrompt: ({ sessionID, id, text }) =>
|
||||
text === "hold" ? [delivered(sessionID, id), textDelta(sessionID, "msg_held", "working")] : turn(sessionID, id),
|
||||
text === "hold"
|
||||
? [
|
||||
delivered(sessionID, id),
|
||||
textDelta(sessionID, "msg_held", "working"),
|
||||
durableEvent("session.step.failed", { sessionID, assistantMessageID: "msg_held", error: lost }),
|
||||
durableEvent("session.retry.scheduled", {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_held",
|
||||
attempt: 1,
|
||||
at: 0,
|
||||
error: lost,
|
||||
}),
|
||||
]
|
||||
: turn(sessionID, id),
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
} satisfies WireOptions
|
||||
|
||||
@@ -144,6 +160,97 @@ describe("acp prompt turns over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("reports full-file diffs for a completed edit and patch", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = (name: string) => path.resolve(dir.path, name)
|
||||
const edited = "one\r\nthree\r\n"
|
||||
const indented = " const x = 1\n const y = 3\n"
|
||||
const formatted = "keep\nnew\n// formatted\n"
|
||||
const added = "two\n"
|
||||
await Promise.all([
|
||||
Bun.write(file("edited.ts"), edited),
|
||||
Bun.write(file("indented.ts"), indented),
|
||||
Bun.write(file("formatted.ts"), formatted),
|
||||
Bun.write(file("added.ts"), added),
|
||||
])
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Update File: indented.ts",
|
||||
"@@",
|
||||
" const x = 1",
|
||||
"- const y = 2",
|
||||
"+ const y = 3",
|
||||
"*** Update File: formatted.ts",
|
||||
"@@",
|
||||
" keep",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** Add File: added.ts",
|
||||
"+two",
|
||||
"*** Delete File: gone.ts",
|
||||
"*** End Patch",
|
||||
].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "edited.ts", oldString: "two", newString: "three" }),
|
||||
toolSucceeded(sessionID, "call_edit", { files: [fileDiff("edited.ts", "one\r\ntwo\r\n", edited)] }, "edited"),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText: patch }),
|
||||
toolSucceeded(
|
||||
sessionID,
|
||||
"call_patch",
|
||||
{
|
||||
files: [
|
||||
trimmed("indented.ts", " const x = 1\n const y = 2\n", indented),
|
||||
fileDiff("formatted.ts", "keep\nold\n", formatted),
|
||||
fileDiff("added.ts", "", added, "added"),
|
||||
fileDiff("gone.ts", "gone\n", "", "deleted"),
|
||||
fileDiff("missing.ts", "one\n", "two\n"),
|
||||
],
|
||||
},
|
||||
"patched",
|
||||
),
|
||||
toolStarted(sessionID, "call_snippet", "edit"),
|
||||
toolCalled(sessionID, "call_snippet", { path: "edited.ts", oldString: "two", newString: "three" }),
|
||||
toolSucceeded(sessionID, "call_snippet", {}, "edited"),
|
||||
),
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "edit the files")
|
||||
|
||||
const completed = (id: string) =>
|
||||
turnUpdates(acp.updates).find(
|
||||
(item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" &&
|
||||
item.update.toolCallId === id &&
|
||||
item.update.status === "completed",
|
||||
)?.update
|
||||
expect(completed("call_edit")).toMatchObject({
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "edited" } },
|
||||
{ type: "diff", path: file("edited.ts"), oldText: "one\r\ntwo\r\n", newText: edited },
|
||||
],
|
||||
})
|
||||
expect(completed("call_patch")).toMatchObject({
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "patched" } },
|
||||
{ type: "diff", path: file("indented.ts"), oldText: " const x = 1\n const y = 2\n", newText: indented },
|
||||
{ type: "diff", path: file("formatted.ts"), oldText: "keep\nold\n", newText: formatted },
|
||||
{ type: "diff", path: file("added.ts"), oldText: null, newText: added },
|
||||
{ type: "diff", path: file("gone.ts"), oldText: "gone\n", newText: "" },
|
||||
],
|
||||
})
|
||||
expect(completed("call_snippet")).toMatchObject({
|
||||
content: [{ type: "content", content: { type: "text", text: "edited" } }],
|
||||
})
|
||||
})
|
||||
|
||||
test("routes slash commands and compact through their session endpoints", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
|
||||
@@ -258,7 +365,9 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toMatchObject(expected)
|
||||
})
|
||||
|
||||
test("session/cancel before admission aborts the submission and returns cancelled", async () => {
|
||||
test("session/cancel during prompt setup or before admission returns cancelled", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ signal }) =>
|
||||
@@ -274,6 +383,15 @@ describe("acp prompt turns over the wire", () => {
|
||||
}),
|
||||
})
|
||||
|
||||
// The linked file's readability check holds the prompt in setup, before it submits.
|
||||
const preparing = acp.prompt(acp.sessionId, [
|
||||
{ type: "resource_link", uri: pathToFileURL(path.join(dir.path, "notes.md")).href, name: "notes.md" },
|
||||
])
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await preparing).toEqual({ stopReason: "cancelled", _meta: {} })
|
||||
expect(acp.server.submissions).toEqual([])
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.server.submissions.length === 1, "prompt submission")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
@@ -387,35 +505,39 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("session/close settles the active turn before responding and detaches only that session", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const other = await acp.newSession()
|
||||
test.each(["session/close", "session/delete"] as const)(
|
||||
"%s settles the active turn before responding and detaches only that session",
|
||||
async (method) => {
|
||||
await using acp = await startSession(held)
|
||||
const other = await acp.newSession()
|
||||
|
||||
const order: string[] = []
|
||||
const prompt = acp.prompt(acp.sessionId, "hold").then((response) => {
|
||||
order.push("prompt")
|
||||
return response
|
||||
})
|
||||
await admitted(acp, acp.sessionId)
|
||||
const close = await acp.request("session/close", { sessionId: acp.sessionId }).then((response) => {
|
||||
order.push("close")
|
||||
return response
|
||||
})
|
||||
const order: string[] = []
|
||||
const prompt = acp.prompt(acp.sessionId, "hold").then((result) => {
|
||||
order.push("prompt")
|
||||
return result
|
||||
})
|
||||
await admitted(acp, acp.sessionId)
|
||||
const response = await acp.request(method, { sessionId: acp.sessionId }).then((result) => {
|
||||
order.push(method)
|
||||
return result
|
||||
})
|
||||
|
||||
expect(close).toEqual({})
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(order).toEqual(["prompt", "close"])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "again"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
expect(response).toEqual({})
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(order).toEqual(["prompt", method])
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId])
|
||||
expect(acp.server.sessions.has(acp.sessionId)).toBe(method === "session/close")
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "again"))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { sessionId: acp.sessionId },
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "still here")).stopReason).toBe("end_turn")
|
||||
|
||||
expect((await acp.prompt(other.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
await acp.request("session/close", { sessionId: other.sessionId })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId, other.sessionId])
|
||||
})
|
||||
expect((await acp.prompt(other.sessionId, "/review now")).stopReason).toBe("end_turn")
|
||||
await acp.request("session/close", { sessionId: other.sessionId })
|
||||
expect(acp.server.interrupts).toEqual([acp.sessionId, other.sessionId])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// The server answered admission before streaming the chunk, and this request round-trips through the server after it,
|
||||
@@ -439,6 +561,28 @@ function receivedBeforeResponse(acp: Wire) {
|
||||
return acp.updates.slice(0, count).map((item) => item.update)
|
||||
}
|
||||
|
||||
function trimmed(file: string, before: string, after: string) {
|
||||
const recorded = fileDiff(file, before, after)
|
||||
const lines = recorded.patch.split("\n")
|
||||
const body = lines.filter((line) => /^[ +-]/.test(line) && !line.startsWith("---") && !line.startsWith("+++"))
|
||||
const indent = body.reduce((result, line) => {
|
||||
const value = line.slice(1)
|
||||
if (value.trim().length === 0) return result
|
||||
return Math.min(result, value.match(/^(\s*)/)?.[1].length ?? result)
|
||||
}, Infinity)
|
||||
if (indent === Infinity || indent === 0) return recorded
|
||||
return {
|
||||
...recorded,
|
||||
patch: lines
|
||||
.map((line) =>
|
||||
/^[ +-]/.test(line) && !line.startsWith("---") && !line.startsWith("+++")
|
||||
? line[0] + line.slice(1 + indent)
|
||||
: line,
|
||||
)
|
||||
.join("\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { assistantMessage, fileDiff, makeSession, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const edited = path.resolve(dir.path, "edited.ts")
|
||||
await Bun.write(edited, "one\r\nthree\r\n")
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_replay", makeSession("ses_replay"))
|
||||
acp.server.messages.set("ses_replay", replayFixtureMessages())
|
||||
acp.server.messages.set("ses_replay", replayFixtureMessages(edited))
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay", mcpServers: [] })
|
||||
@@ -26,6 +31,8 @@ describe("acp session replay over the wire", () => {
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
"tool_call",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
])
|
||||
expect(updates[1]?.update).toMatchObject({
|
||||
content: { type: "resource_link", uri: "file:///workspace/note.md", name: "note.md", mimeType: "text/markdown" },
|
||||
@@ -57,11 +64,20 @@ describe("acp session replay over the wire", () => {
|
||||
{ type: "content", content: { type: "text", text: "failed hard" } },
|
||||
],
|
||||
})
|
||||
expect(updates[5]?.update).toMatchObject({ name: "shell" })
|
||||
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
|
||||
expect(updates[13]?.update).toMatchObject({
|
||||
toolCallId: "call_edit",
|
||||
status: "completed",
|
||||
content: [
|
||||
{ type: "content", content: { type: "text", text: "edited" } },
|
||||
{ type: "diff", path: edited, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
function replayFixtureMessages(edited: string): Array<typeof SessionMessage.Info.Encoded> {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
@@ -120,6 +136,18 @@ function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
time: { created: 2 },
|
||||
state: { status: "streaming", input: '{"command":' },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_edit",
|
||||
name: "edit",
|
||||
time: { created: 2, completed: 3 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { path: edited, oldString: "two", newString: "three" },
|
||||
metadata: { files: [fileDiff(edited, "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
content: [{ type: "text", text: "edited" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -10,6 +10,10 @@ describe("acp session lifecycle over the wire", () => {
|
||||
|
||||
const plain = await acp.initialize()
|
||||
const terminal = await acp.initialize({ terminalAuth: true, childSessionUpdates: true })
|
||||
const standard = await acp.request("initialize", {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
})
|
||||
|
||||
expect(plain).toMatchObject({
|
||||
protocolVersion: 1,
|
||||
@@ -28,6 +32,15 @@ describe("acp session lifecycle over the wire", () => {
|
||||
expect(terminal.authMethods?.[0]?._meta).toEqual({
|
||||
"terminal-auth": { command: "opencode", args: ["auth", "login"], label: "OpenCode Login" },
|
||||
})
|
||||
expect(standard.authMethods).toEqual([
|
||||
{
|
||||
id: "opencode-login",
|
||||
name: "Login with opencode",
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
type: "terminal",
|
||||
args: ["--login"],
|
||||
},
|
||||
])
|
||||
expect(await acp.request("authenticate", { methodId: "opencode-login" })).toEqual({})
|
||||
expect(await rpcError(acp.request("authenticate", { methodId: "missing" }))).toMatchObject({
|
||||
code: -32602,
|
||||
@@ -66,6 +79,11 @@ describe("acp session lifecycle over the wire", () => {
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded", cwd: "/elsewhere" } })
|
||||
const sessions = new Set(acp.server.sessions.keys())
|
||||
expect(
|
||||
await rpcError(acp.request("session/fork", { cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded", cwd: "/elsewhere" } })
|
||||
expect(new Set(acp.server.sessions.keys())).toEqual(sessions)
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_missing", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_missing" } })
|
||||
@@ -165,7 +183,7 @@ describe("acp session lifecycle over the wire", () => {
|
||||
config: { type: "remote", url: "https://example.com/mcp", headers: { Authorization: "Bearer x" }, oauth: false },
|
||||
})
|
||||
})
|
||||
test("rejects MCP-over-ACP and SSE servers before creating or loading a session", async () => {
|
||||
test("rejects relative cwds, MCP-over-ACP, and SSE servers before creating or loading a session", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", makeSession("ses_saved"))
|
||||
await acp.initialize()
|
||||
@@ -177,12 +195,26 @@ describe("acp session lifecycle over the wire", () => {
|
||||
message: "Invalid params: Only stdio and HTTP MCP servers are supported",
|
||||
data: { field: "mcpServers" },
|
||||
}
|
||||
const relative = {
|
||||
code: -32602,
|
||||
message: "Invalid params: cwd must be an absolute path: workspace",
|
||||
data: { field: "cwd" },
|
||||
}
|
||||
|
||||
expect(await rpcError(acp.newSession("/workspace", mcpServers))).toEqual(invalid)
|
||||
expect(await rpcError(acp.newSession("/workspace", sse))).toEqual(invalid)
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers })),
|
||||
).toEqual(invalid)
|
||||
expect(await rpcError(acp.newSession("workspace"))).toEqual(relative)
|
||||
expect(
|
||||
await rpcError(acp.request("session/load", { cwd: "workspace", sessionId: "ses_saved", mcpServers: [] })),
|
||||
).toEqual(relative)
|
||||
expect(await rpcError(acp.request("session/list", { cwd: "workspace" }))).toEqual(relative)
|
||||
expect(await rpcError(acp.request("session/list", { cwd: "" }))).toMatchObject({
|
||||
code: -32602,
|
||||
data: { field: "cwd" },
|
||||
})
|
||||
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
|
||||
expect(acp.server.requests.filter((request) => request.path.includes("ses_saved"))).toEqual([])
|
||||
expect(acp.logs).toEqual([])
|
||||
|
||||
@@ -225,7 +225,7 @@ const rows: Row[] = [
|
||||
),
|
||||
expected: {
|
||||
updates: [
|
||||
{ sessionUpdate: "tool_call", toolCallId: "call_ok", status: "pending", kind: "execute" },
|
||||
{ sessionUpdate: "tool_call", toolCallId: "call_ok", name: "shell", status: "pending", kind: "execute" },
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
status: "in_progress",
|
||||
|
||||
@@ -24,10 +24,12 @@ import type { Command } from "@opencode/schema/command"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import type { Location } from "@opencode/schema/location"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Plugin } from "@opencode/schema/plugin"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { BunRequest } from "bun"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
@@ -40,6 +42,7 @@ type CommandInfo = typeof Command.Info.Encoded
|
||||
type LocationRef = typeof Location.PublicRef.Encoded
|
||||
type ModelInfo = typeof Model.Info.Encoded
|
||||
type ModelRef = typeof Model.Ref.Encoded
|
||||
type PluginInfo = typeof Plugin.Info.Encoded
|
||||
type SessionInfo = typeof Session.Info.Encoded
|
||||
type SessionMessageInfo = typeof SessionMessage.Info.Encoded
|
||||
type TokenUsageInfo = typeof TokenUsage.Info.Encoded
|
||||
@@ -155,6 +158,7 @@ type Catalog = {
|
||||
models: ModelInfo[]
|
||||
agents: AgentInfo[]
|
||||
commands: CommandInfo[]
|
||||
plugins: PluginInfo[]
|
||||
}
|
||||
|
||||
export type InitializeOptions = {
|
||||
@@ -337,6 +341,15 @@ export function toolProgress(sessionID: string, id: string, metadata: EventData<
|
||||
return ephemeralEvent("session.tool.progress", { sessionID, assistantMessageID: "msg_tools", id, metadata })
|
||||
}
|
||||
|
||||
export function fileDiff(
|
||||
file: string,
|
||||
before: string,
|
||||
after: string,
|
||||
status: "added" | "deleted" | "modified" = "modified",
|
||||
) {
|
||||
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
||||
}
|
||||
|
||||
export function toolSucceeded(
|
||||
sessionID: string,
|
||||
id: string,
|
||||
@@ -559,6 +572,14 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
models: [testModel, secondModel],
|
||||
agents: [buildAgent, planAgent],
|
||||
commands: [reviewCommand],
|
||||
plugins: [
|
||||
{
|
||||
id: "opencode.models.dev",
|
||||
source: { type: "builtin" },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
},
|
||||
],
|
||||
}
|
||||
const requests: ServerRequest[] = []
|
||||
const submissions: Submission[] = []
|
||||
@@ -697,6 +718,7 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
"/api/model/default": { GET: catalogRoute(() => catalog.models[0] ?? null) },
|
||||
"/api/agent": { GET: catalogRoute(() => catalog.agents) },
|
||||
"/api/command": { GET: catalogRoute(() => catalog.commands) },
|
||||
"/api/plugin": { GET: catalogRoute(() => catalog.plugins) },
|
||||
"/api/session": {
|
||||
GET: route((_req, query) => {
|
||||
const sessions = [...fake.sessions.values()]
|
||||
@@ -729,6 +751,9 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
})
|
||||
return noContent()
|
||||
}),
|
||||
DELETE: route((req) =>
|
||||
fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID),
|
||||
),
|
||||
},
|
||||
"/api/session/:sessionID/fork": {
|
||||
POST: route((req) => {
|
||||
|
||||
@@ -289,11 +289,14 @@ describe("auth command", () => {
|
||||
expect(removed).toEqual(["cred_test"])
|
||||
})
|
||||
|
||||
test("requires a target outside an interactive terminal", async () => {
|
||||
const result = await cli(["auth", "login"])
|
||||
test("requires a target outside an interactive terminal, including through acp --login", async () => {
|
||||
const [result, acp] = await Promise.all([cli(["auth", "login"]), cli(["acp", "--login"])])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Pass an integration ID or name")
|
||||
expect(result.stdout).not.toContain("Background service failed to start")
|
||||
expect(acp.exitCode).toBe(1)
|
||||
expect(acp.stdout).toContain("Pass an integration ID or name when running without an interactive terminal")
|
||||
expect(acp.stdout).not.toContain("Background service failed to start")
|
||||
})
|
||||
|
||||
test("reports list connection failures without a stack trace", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -55,7 +55,6 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"zod": "catalog:"
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
|
||||
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
@@ -89,30 +89,20 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
|
||||
),
|
||||
)
|
||||
if (compatible && service.state === "ready") {
|
||||
const decision = decide(service, options)
|
||||
if (decision._tag === "fail") return yield* Effect.fail(decision.error)
|
||||
if (decision._tag === "reuse") {
|
||||
yield* Effect.tryPromise(() => PtyHandoff.complete(options.file ?? fallback(), service.info))
|
||||
return Option.some(service)
|
||||
}
|
||||
if (compatible && service.state === "failed")
|
||||
return yield* Effect.fail(new Error("Background service failed to start"))
|
||||
if (compatible) return Option.none<LocalService>()
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (service.state !== "ready")
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* stop({
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
pool.evict(service.info.pid)
|
||||
return Option.none<LocalService>()
|
||||
if (decision._tag === "replace") {
|
||||
yield* announce("version-mismatch", service.version)
|
||||
if (service.state !== "ready")
|
||||
yield* Effect.logWarning("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
yield* stop({ file: options.file, pty: decision.pty }).pipe(Effect.ignore)
|
||||
pool.evict(service.info.pid)
|
||||
}
|
||||
return Option.none()
|
||||
}
|
||||
|
||||
const failed = pool.reap()
|
||||
@@ -121,7 +111,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
yield* announce("missing")
|
||||
pool.add(yield* spawnContender)
|
||||
}
|
||||
return Option.none<LocalService>()
|
||||
return Option.none()
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
|
||||
@@ -308,6 +308,8 @@ export type CredentialOAuth = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type CredentialExternal = { type: "external"; methodID: string; metadata: { [x: string]: JsonValue } }
|
||||
|
||||
export type ProjectVcs = string
|
||||
|
||||
export type ProjectIcon = { url?: string; override?: string; color?: string }
|
||||
@@ -1477,6 +1479,8 @@ export type ModelCompatibility = {
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
supportsPromptCacheKey?: boolean
|
||||
supportsThinkingBlockBinding?: boolean
|
||||
supportsEffortUpdates?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1490,7 +1494,7 @@ export type ConnectionCredentialInfo = {
|
||||
type: "credential"
|
||||
id: string
|
||||
label: string
|
||||
method: "key" | "oauth"
|
||||
method: "key" | "oauth" | "external"
|
||||
status?: ConnectionStatus
|
||||
}
|
||||
|
||||
@@ -2254,7 +2258,7 @@ export type SessionMessageAssistantTool1 = {
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type CredentialValue = CredentialOAuth | CredentialKey
|
||||
export type CredentialValue = CredentialOAuth | CredentialKey | CredentialExternal
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
@@ -5852,6 +5856,7 @@ export type CredentialCreateInput = {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| { readonly type: "external"; readonly methodID: string; readonly metadata: { readonly [x: string]: JsonValue } }
|
||||
readonly activate?: boolean
|
||||
}["id"]
|
||||
readonly integrationID: {
|
||||
@@ -5875,6 +5880,7 @@ export type CredentialCreateInput = {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| { readonly type: "external"; readonly methodID: string; readonly metadata: { readonly [x: string]: JsonValue } }
|
||||
readonly activate?: boolean
|
||||
}["integrationID"]
|
||||
readonly label?: {
|
||||
@@ -5898,6 +5904,7 @@ export type CredentialCreateInput = {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| { readonly type: "external"; readonly methodID: string; readonly metadata: { readonly [x: string]: JsonValue } }
|
||||
readonly activate?: boolean
|
||||
}["label"]
|
||||
readonly value: {
|
||||
@@ -5921,6 +5928,7 @@ export type CredentialCreateInput = {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| { readonly type: "external"; readonly methodID: string; readonly metadata: { readonly [x: string]: JsonValue } }
|
||||
readonly activate?: boolean
|
||||
}["value"]
|
||||
readonly activate?: {
|
||||
@@ -5944,6 +5952,7 @@ export type CredentialCreateInput = {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
| { readonly type: "external"; readonly methodID: string; readonly metadata: { readonly [x: string]: JsonValue } }
|
||||
readonly activate?: boolean
|
||||
}["activate"]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
import { decide, fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
@@ -71,25 +71,17 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
if (registration.service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
const service = registration.service
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
throw new Error(
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
|
||||
)
|
||||
if (compatible && service.state === "ready") {
|
||||
const decision = decide(service, options)
|
||||
if (decision._tag === "fail") throw decision.error
|
||||
if (decision._tag === "reuse") {
|
||||
await PtyHandoff.complete(options.file ?? fallback(), service.info)
|
||||
return service.endpoint
|
||||
}
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
if (decision._tag === "replace") {
|
||||
announce("version-mismatch", service.version)
|
||||
if (service.state !== "ready")
|
||||
console.warn("Background service is not ready; replacement cannot preserve persistent terminals")
|
||||
await stop({
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
await stop({ file: options.file, pty: decision.pty }).catch(() => undefined)
|
||||
pool.evict(service.info.pid)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Endpoint, Info } from "./service.js"
|
||||
import type { DiscoverOptions, Endpoint, Info } from "./service.js"
|
||||
import { defaultEnsureTiming } from "./service-timing.js"
|
||||
import { matchesVersion } from "./service-version.js"
|
||||
|
||||
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
|
||||
// Promise client never loads the Effect runtime.
|
||||
@@ -14,6 +15,26 @@ export type LocalService = {
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* What `ensure()` does with a registered service that answered: return it, keep polling while
|
||||
* it starts, replace it because its version does not meet the requirement, or fail.
|
||||
*/
|
||||
export function decide(service: LocalService, options: DiscoverOptions) {
|
||||
if (!matchesVersion(service.version, options))
|
||||
return { _tag: "replace", pty: service.state === "ready" ? "handoff" : "clear" } as const
|
||||
if (!service.compatible)
|
||||
return fail(
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service.",
|
||||
)
|
||||
if (service.state === "ready") return { _tag: "reuse" } as const
|
||||
if (service.state === "failed") return fail("Background service failed to start")
|
||||
return { _tag: "wait" } as const
|
||||
}
|
||||
|
||||
function fail(message: string) {
|
||||
return { _tag: "fail" as const, error: new Error(message) }
|
||||
}
|
||||
|
||||
/** The default registration file. */
|
||||
export function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { decide, type LocalService } from "../src/service-probe"
|
||||
|
||||
const protocolError =
|
||||
"Background service uses an incompatible health protocol. Update this client or explicitly restart the service."
|
||||
|
||||
function service(input: Pick<LocalService, "state" | "compatible" | "version">) {
|
||||
return {
|
||||
info: { url: "http://127.0.0.1:4096", pid: 4242, version: input.version },
|
||||
endpoint: { url: "http://127.0.0.1:4096" },
|
||||
...input,
|
||||
} satisfies LocalService
|
||||
}
|
||||
|
||||
test("a missing health endpoint fails unless the caller requires a different version", () => {
|
||||
const missing = service({ version: "2.0.0", state: "ready", compatible: false })
|
||||
expect(decide(missing, {})).toEqual({ _tag: "fail", error: new Error(protocolError) })
|
||||
expect(decide(missing, { version: "2.0.0" })).toEqual({ _tag: "fail", error: new Error(protocolError) })
|
||||
expect(decide(missing, { version: (version) => version.startsWith("2.") })).toEqual({
|
||||
_tag: "fail",
|
||||
error: new Error(protocolError),
|
||||
})
|
||||
expect(decide(missing, { version: "3.0.0" })).toEqual({ _tag: "replace", pty: "handoff" })
|
||||
expect(decide(service({ state: "ready", compatible: false }), { version: "3.0.0" })).toEqual({
|
||||
_tag: "replace",
|
||||
pty: "handoff",
|
||||
})
|
||||
})
|
||||
|
||||
test("a compatible service is reused when ready, awaited while starting, and fails when it failed", () => {
|
||||
expect(decide(service({ version: "2.0.0", state: "ready", compatible: true }), {})).toEqual({ _tag: "reuse" })
|
||||
expect(decide(service({ version: "2.0.0", state: "waiting", compatible: true }), { version: "2.0.0" })).toEqual({
|
||||
_tag: "wait",
|
||||
})
|
||||
expect(decide(service({ version: "2.0.0", state: "failed", compatible: true }), {})).toEqual({
|
||||
_tag: "fail",
|
||||
error: new Error("Background service failed to start"),
|
||||
})
|
||||
})
|
||||
|
||||
test("a version mismatch replaces the service, handing off terminals only when it is ready", () => {
|
||||
expect(decide(service({ version: "1.0.0", state: "ready", compatible: true }), { version: "2.0.0" })).toEqual({
|
||||
_tag: "replace",
|
||||
pty: "handoff",
|
||||
})
|
||||
expect(decide(service({ version: "1.0.0", state: "waiting", compatible: true }), { version: "2.0.0" })).toEqual({
|
||||
_tag: "replace",
|
||||
pty: "clear",
|
||||
})
|
||||
expect(decide(service({ version: "1.0.0", state: "failed", compatible: true }), { version: "2.0.0" })).toEqual({
|
||||
_tag: "replace",
|
||||
pty: "clear",
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.24",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -120,13 +120,11 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.18.0",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gitlab-ai-provider": "6.19.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
export * as Agent from "./agent.js"
|
||||
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
const SHELL_OUTPUT_GLOB = (data: string) => path.join(data, "shell", "*", "*")
|
||||
const TOOL_OUTPUT_GLOB = (data: string) => path.join(data, "tool-output", "*")
|
||||
|
||||
export const ID = Agent.ID
|
||||
export type ID = typeof ID.Type
|
||||
export const Name = Agent.Name
|
||||
@@ -55,13 +50,6 @@ const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const global = yield* Global.Service
|
||||
const permissions: Info["permissions"] = [
|
||||
{ action: "external_directory", resource: SHELL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: TOOL_OUTPUT_GLOB(global.data), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
const state = State.create<Data, Editor>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
@@ -72,13 +60,7 @@ const layer = Layer.effect(
|
||||
editor.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const defaults = Info.default(id)
|
||||
const current =
|
||||
editor.agents.get(id) ??
|
||||
({
|
||||
...defaults,
|
||||
permissions: [...defaults.permissions, ...permissions],
|
||||
} as Types.DeepMutable<Info>)
|
||||
const current = editor.agents.get(id) ?? (Info.default(id) as Types.DeepMutable<Info>)
|
||||
if (!editor.agents.has(id)) editor.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
@@ -131,4 +113,4 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Global.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
@@ -19,6 +19,9 @@ export type OAuth = Credential.OAuth
|
||||
export const Key = Credential.Key
|
||||
export type Key = Credential.Key
|
||||
|
||||
export const External = Credential.External
|
||||
export type External = Credential.External
|
||||
|
||||
export const Value = Credential.Value
|
||||
export type Value = Credential.Value
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
readonly connection: {
|
||||
/** Returns the active connection for one integration. */
|
||||
readonly active: (id: ID) => Effect.Effect<IntegrationConnection.Info | undefined>
|
||||
/** Resolves a connection into usable credential material. */
|
||||
/** Resolves a connection into credential material or an external credential-source reference. */
|
||||
readonly resolve: (
|
||||
connection: IntegrationConnection.Info,
|
||||
) => Effect.Effect<Credential.Value | undefined, AuthorizationError>
|
||||
@@ -700,7 +700,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
const credential = yield* credentials.get(connection.id)
|
||||
if (!credential) return undefined
|
||||
if (credential.value.type === "key") return credential.value
|
||||
if (credential.value.type !== "oauth") return credential.value
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(credential.integrationID)
|
||||
|
||||
@@ -306,7 +306,7 @@ function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (!credential || credential.type === "external") return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (specifier === "@opencode/ai/providers/anthropic" || specifier === "@opencode/ai/providers/anthropic-compatible")
|
||||
return { authToken: credential.access }
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -3,7 +3,6 @@ export * as AgentPlugin from "./agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
@@ -97,31 +96,24 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("explore"), (item) => {
|
||||
const externalDirectories = item.permissions.filter(
|
||||
(rule) => rule.action === "external_directory" && rule.effect === "allow",
|
||||
)
|
||||
item.name = Agent.Name.make("Explore")
|
||||
item.description =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
item.system = PROMPT_EXPLORE
|
||||
item.mode = "subagent"
|
||||
item.permissions.push(
|
||||
...Permission.merge(
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
],
|
||||
[{ action: "external_directory", resource: "*", effect: "ask" }, ...externalDirectories],
|
||||
),
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.*", effect: "ask" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
{ action: "subagent", resource: "*", effect: "deny" },
|
||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ export const Plugin = define({
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
item.permissions.push({ action: "edit", resource: "*", effect: "deny" })
|
||||
item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" })
|
||||
item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ export function make(
|
||||
|
||||
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
|
||||
url: string,
|
||||
credential: Credential.Value,
|
||||
credential: Credential.Key | Credential.OAuth,
|
||||
) {
|
||||
return yield* http
|
||||
.execute(
|
||||
@@ -266,7 +266,7 @@ export function make(
|
||||
// data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists
|
||||
// models the resource can deploy rather than its deployments.
|
||||
// https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json
|
||||
const deployments = (url: string, resource: string, credential: Credential.Value) =>
|
||||
const deployments = (url: string, resource: string, credential: Credential.Key | Credential.OAuth) =>
|
||||
credential.type === "oauth"
|
||||
? managementDeployments(resource).pipe(Effect.catch(() => resourceDeployments(url, credential)))
|
||||
: resourceDeployments(url, credential)
|
||||
@@ -308,7 +308,12 @@ export function make(
|
||||
const credential = yield* ctx.integration.connection
|
||||
.resolve(connection)
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
|
||||
if (
|
||||
!credential ||
|
||||
credential.type === "external" ||
|
||||
(credential.type === "oauth" && credential.methodID !== methodID)
|
||||
)
|
||||
return
|
||||
const found = yield* deployments(url, name, credential).pipe(
|
||||
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
|
||||
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
// import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Server, ServerResponse } from "node:http"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
// import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
@@ -36,6 +37,12 @@ const fallbackModels = new Set([
|
||||
"gpt-5.6-terra-fast",
|
||||
"gpt-6-astra",
|
||||
"gpt-6-astra-fast",
|
||||
"gpt-6-luna",
|
||||
"gpt-6-luna-fast",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-sol-fast",
|
||||
"gpt-6.1-sol",
|
||||
"gpt-6.1-sol-fast",
|
||||
])
|
||||
const nonRetryableSharingCodes = [
|
||||
"subscription_sharing_usage_limit_exceeded",
|
||||
@@ -204,22 +211,22 @@ export const ChatGPTPlugin = define({
|
||||
const credentials = yield* Credential.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
let available: ReadonlyArray<RemoteModel> | undefined
|
||||
// let available: ReadonlyArray<RemoteModel> | undefined
|
||||
let source: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
|
||||
const load = Effect.fn("ChatGPTPlugin.load")(function* () {
|
||||
const previous = IntegrationConnection.key(source)
|
||||
// const previous = IntegrationConnection.key(source)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
chatgpt = credential?.type === "oauth" && credential.methodID === methodID ? credential : undefined
|
||||
source = chatgpt ? connection : undefined
|
||||
if (previous !== IntegrationConnection.key(source)) {
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))
|
||||
const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined
|
||||
available = Option.getOrUndefined(decodeCachedModels(stored))
|
||||
}
|
||||
// if (previous !== IntegrationConnection.key(source)) {
|
||||
// const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))
|
||||
// const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined
|
||||
// available = Option.getOrUndefined(decodeCachedModels(stored))
|
||||
// }
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
@@ -272,9 +279,10 @@ export const ChatGPTPlugin = define({
|
||||
if (!updated) return
|
||||
providers.add({
|
||||
info: updated.provider,
|
||||
models: available
|
||||
? deriveModels(available, Array.from(item.models.values()))
|
||||
: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
|
||||
// models: available
|
||||
// ? deriveModels(available, Array.from(item.models.values()))
|
||||
// : Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
|
||||
models: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
|
||||
sourceConnection: source,
|
||||
})
|
||||
})
|
||||
@@ -288,55 +296,53 @@ export const ChatGPTPlugin = define({
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
// if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) {
|
||||
// draft.enabled = false
|
||||
// return
|
||||
// }
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
})
|
||||
const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () {
|
||||
const connection = source
|
||||
const credential =
|
||||
connection?.type === "credential"
|
||||
? (yield* credentials.get(Credential.ID.make(connection.id)))?.value
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000)
|
||||
return
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata))
|
||||
if (!metadata) return
|
||||
const models = yield* fetchModels(credential.access, ctx.app).pipe(
|
||||
Effect.timeout(15_000),
|
||||
Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
|
||||
)
|
||||
if (!models) return
|
||||
yield* loading.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
IntegrationConnection.key(connection) !== IntegrationConnection.key(source) ||
|
||||
IntegrationConnection.key(connection) !==
|
||||
IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID))
|
||||
)
|
||||
return
|
||||
if (JSON.stringify(models) === JSON.stringify(available)) return
|
||||
yield* ctx.storage.set(modelCacheKey(metadata.clientID), models)
|
||||
available = models
|
||||
yield* ctx.provider.reload()
|
||||
}),
|
||||
)
|
||||
})
|
||||
const reload = () =>
|
||||
loading
|
||||
.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
.pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid)))
|
||||
// const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () {
|
||||
// const connection = source
|
||||
// const credential =
|
||||
// connection?.type === "credential"
|
||||
// ? (yield* credentials.get(Credential.ID.make(connection.id)))?.value
|
||||
// : undefined
|
||||
// if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000)
|
||||
// return
|
||||
// const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata))
|
||||
// if (!metadata) return
|
||||
// const models = yield* fetchModels(credential.access, ctx.app).pipe(
|
||||
// Effect.timeout(15_000),
|
||||
// Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
|
||||
// )
|
||||
// if (!models) return
|
||||
// yield* loading.withPermit(
|
||||
// Effect.gen(function* () {
|
||||
// if (
|
||||
// IntegrationConnection.key(connection) !== IntegrationConnection.key(source) ||
|
||||
// IntegrationConnection.key(connection) !==
|
||||
// IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID))
|
||||
// )
|
||||
// return
|
||||
// if (JSON.stringify(models) === JSON.stringify(available)) return
|
||||
// yield* ctx.storage.set(modelCacheKey(metadata.clientID), models)
|
||||
// available = models
|
||||
// yield* ctx.provider.reload()
|
||||
// }),
|
||||
// )
|
||||
// })
|
||||
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
// .pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid)))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refreshModels().pipe(Effect.forkScoped)
|
||||
yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped)
|
||||
// yield* refreshModels().pipe(Effect.forkScoped)
|
||||
// yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export const GitLabPlugin = define({
|
||||
const credential = stored
|
||||
? yield* ctx.integration.connection.resolve(stored).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
if (!stored || !credential) {
|
||||
if (!stored || !credential || credential.type === "external") {
|
||||
loaded.models = undefined
|
||||
loaded.connection = undefined
|
||||
return
|
||||
|
||||
@@ -55,7 +55,7 @@ const browser = (app: App.Info) =>
|
||||
method: {
|
||||
id: browserMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (browser)",
|
||||
label: "Codex browser (legacy)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -123,7 +123,7 @@ function listen(server: Server) {
|
||||
Effect.catchIf(addressInUse, () =>
|
||||
Effect.fail(
|
||||
new Error(
|
||||
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose ChatGPT Pro/Plus (headless), then try again.`,
|
||||
`OpenAI browser login needs local port ${callbackPort} or ${callbackFallbackPort}, but both are already in use. Stop the processes using those ports or choose Codex device code (legacy), then try again.`,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -174,7 +174,7 @@ const headless = (app: App.Info) =>
|
||||
method: {
|
||||
id: headlessMethodID,
|
||||
type: "oauth",
|
||||
label: "ChatGPT Pro/Plus (headless)",
|
||||
label: "Codex device code (legacy)",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -201,7 +201,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
}
|
||||
return yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => {
|
||||
if (!credential)
|
||||
if (!credential || credential.type === "external")
|
||||
return Effect.succeed({ config: undefined, connection, organization: undefined, mcp: undefined })
|
||||
return fetchConfig(http, credential).pipe(
|
||||
Effect.map((config) => ({
|
||||
@@ -360,7 +360,8 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
return yield* Effect.fail(new Error("OpenCode Console connection changed"))
|
||||
}
|
||||
const credential = yield* ctx.integration.connection.resolve(active)
|
||||
if (!credential) return yield* Effect.fail(new Error("OpenCode Console is not connected"))
|
||||
if (!credential || credential.type === "external")
|
||||
return yield* Effect.fail(new Error("OpenCode Console is not connected"))
|
||||
const metadata = credential.metadata
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
const token = credential.type === "oauth" ? credential.access : credential.key
|
||||
@@ -469,7 +470,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
}),
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Key | Credential.OAuth) {
|
||||
// Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort.
|
||||
return HttpClient.withScope(http)
|
||||
.execute(
|
||||
@@ -532,7 +533,7 @@ function organizationName(credential: Credential.Value) {
|
||||
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||
}
|
||||
|
||||
function credentialHeaders(value: Credential.Value): Record<string, string> {
|
||||
function credentialHeaders(value: Credential.Key | Credential.OAuth): Record<string, string> {
|
||||
const orgID = value.metadata?.orgID
|
||||
return {
|
||||
authorization: `Bearer ${value.type === "oauth" ? value.access : value.key}`,
|
||||
|
||||
@@ -308,7 +308,7 @@ function registrationError(tool: Tool.Info) {
|
||||
if (error) return error
|
||||
}
|
||||
const name = normalizedName(tool)
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(name)) return new RegistrationError({ name, message: `Invalid tool name: ${name}` })
|
||||
const id = effectiveName(tool)
|
||||
if (tool.options?.codemode === false && id === "execute")
|
||||
return new RegistrationError({ name: id, message: 'Tool name "execute" is reserved for CodeMode' })
|
||||
@@ -321,7 +321,7 @@ function registrationError(tool: Tool.Info) {
|
||||
}
|
||||
|
||||
function namespaceError(name: string) {
|
||||
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]{1,64}$/.test(segment))) return
|
||||
if (name.split(".").every((segment) => /^[A-Za-z0-9_-]+$/.test(segment))) return
|
||||
return new RegistrationError({ name, message: `Invalid tool namespace: ${JSON.stringify(name)}` })
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,9 @@ export const Plugin = {
|
||||
const target = yield* access.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
invocation.cwd = target.absolute
|
||||
const timeout = invocation.timeout
|
||||
const portable = Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const portable =
|
||||
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner ??
|
||||
(ctx.app.channel === "local" || ctx.app.channel === "dev")
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, { portable })
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
access.resolve({
|
||||
|
||||
@@ -168,7 +168,26 @@ export function providerID(input: string) {
|
||||
}
|
||||
|
||||
function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
const settings = info.options && ConfigProviderOptionsV1.model(info.options)
|
||||
const disableThinkingBlockBinding =
|
||||
info.options?.thinking?.blockBinding === false || info.options?.reasoningConfig?.blockBinding === false
|
||||
const options = info.options && { ...info.options }
|
||||
|
||||
// Move the legacy opt-out to compatibility without mutating the input.
|
||||
if (options && disableThinkingBlockBinding) {
|
||||
for (const key of ["thinking", "reasoningConfig"]) {
|
||||
if (options[key]?.blockBinding !== false) continue
|
||||
|
||||
const { blockBinding, ...rest } = options[key]
|
||||
if (Object.keys(rest).length) {
|
||||
options[key] = rest
|
||||
continue
|
||||
}
|
||||
delete options[key]
|
||||
}
|
||||
}
|
||||
|
||||
const settings = options && ConfigProviderOptionsV1.model(options)
|
||||
const compatibility = Model.compatibility(info.interleaved)
|
||||
const costs = info.cost && [
|
||||
{
|
||||
input: info.cost.input,
|
||||
@@ -199,7 +218,9 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
modelID: info.id,
|
||||
family: info.family,
|
||||
name: info.name,
|
||||
compatibility: Model.compatibility(info.interleaved),
|
||||
compatibility: disableThinkingBlockBinding
|
||||
? { ...compatibility, supportsThinkingBlockBinding: false }
|
||||
: compatibility,
|
||||
package: info.provider?.npm ? Provider.aisdk(info.provider.npm) : undefined,
|
||||
settings: info.provider?.api ? { ...settings, baseURL: info.provider.api } : settings,
|
||||
capabilities,
|
||||
|
||||
@@ -526,6 +526,43 @@ const sapAICore: Protocol = (model, support) => {
|
||||
}
|
||||
}
|
||||
|
||||
// gitlab-ai-provider reads `providerOptions.gitlab.thinking` (Anthropic) and
|
||||
// `providerOptions.gitlab.reasoningEffort` (OpenAI Chat and Responses). Its adaptive
|
||||
// thinking config carries the effort inline and always requests summarized display.
|
||||
const gitlabAnthropic: Protocol = (model, support) => {
|
||||
const info = claudeInfo(model)
|
||||
const disabled: Overlay = { settings: { thinking: { type: "disabled" } } }
|
||||
const adaptive = (effort: string): Overlay => ({ settings: { thinking: { type: "adaptive", effort } } })
|
||||
switch (support.type) {
|
||||
case "effort": {
|
||||
if (info.manual) return gitlabAnthropic(model, { type: "budget_tokens", min: 1024 })
|
||||
const defaults = info.major === 4 && info.minor === 6 ? [...EFFORTS, "max"] : [...EFFORTS, "xhigh", "max"]
|
||||
return efforts(support.values ?? defaults, (effort) => (effort === "none" ? disabled : adaptive(effort)))
|
||||
}
|
||||
case "toggle": {
|
||||
if (info.always) return []
|
||||
const thinking = info.manual ? manualThinking(model) : adaptive("high")
|
||||
if (!thinking) return []
|
||||
return toggle(disabled, thinking)
|
||||
}
|
||||
case "budget_tokens":
|
||||
return budgets(
|
||||
model,
|
||||
support,
|
||||
(tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } } }),
|
||||
ANTHROPIC_OUTPUT_TOKEN_MAX,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const gitlab: Protocol = (model, support) => {
|
||||
const id = modelID(model).toLowerCase()
|
||||
if (id.startsWith("duo-workflow")) return []
|
||||
if (claudeInfo(model).family) return gitlabAnthropic(model, support)
|
||||
if (id.includes("gpt") || id.includes("codex")) return openaiChat(model, support)
|
||||
return []
|
||||
}
|
||||
|
||||
const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/openai": openaiResponses,
|
||||
"@opencode/ai/providers/azure/responses": openaiResponses,
|
||||
@@ -580,4 +617,5 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/vercel-ai-gateway": vercelGateway,
|
||||
[Provider.aisdk("@ai-sdk/gateway")]: vercelGateway,
|
||||
[Provider.aisdk("@jerome-benoit/sap-ai-provider-v2")]: sapAICore,
|
||||
[Provider.aisdk("gitlab-ai-provider")]: gitlab,
|
||||
}
|
||||
@@ -167,7 +167,7 @@ describe("Agent", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies managed external directories without opting built-in agents into bash", () =>
|
||||
it.effect("allows external directories without opting built-in agents into bash", () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
yield* AgentPlugin.Plugin.effect(
|
||||
@@ -202,6 +202,7 @@ describe("Agent", () => {
|
||||
).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", "/outside/*", permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
|
||||
@@ -209,6 +210,7 @@ describe("Agent", () => {
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", "src/index.ts", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", "/outside/*", explore?.permissions ?? []).effect).toBe("allow")
|
||||
for (const item of agents) {
|
||||
expect(item.permissions.some((rule) => rule.action === "bash" && rule.effect !== "deny")).toBe(false)
|
||||
}
|
||||
|
||||
@@ -30,13 +30,7 @@ function migrateV1(input: unknown) {
|
||||
if (result.type !== "normalized") throw new Error("expected normalized config")
|
||||
return result.encoded
|
||||
}
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
{ action: "external_directory", resource: path.join(global.data, "shell", "*", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.data, "tool-output", "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
const defaultPermissions = Agent.Info.default(Agent.ID.make("test")).permissions
|
||||
|
||||
test("rejects named agent color tokens", () => {
|
||||
expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow()
|
||||
@@ -229,7 +223,7 @@ permissions:
|
||||
const opencodeData = path.join(global.home, ".local", "share", "opencode", "*")
|
||||
const mcpAuth = path.join(global.home, ".local", "share", "opencode", "mcp-auth.json")
|
||||
expect(build.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "ask" },
|
||||
@@ -260,7 +254,6 @@ permissions:
|
||||
it.effect("applies all global permissions before agent-specific permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) =>
|
||||
editor.update(build, (agent) => {
|
||||
@@ -313,7 +306,7 @@ permissions:
|
||||
const buildAgent = yield* agents.get(build)
|
||||
if (!buildAgent) throw new Error("expected configured build agent")
|
||||
expect(buildAgent.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
@@ -331,7 +324,7 @@ permissions:
|
||||
model: { providerID: "openrouter", id: "openai/gpt-5", variant: "high" },
|
||||
})
|
||||
expect(reviewer.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
@@ -339,7 +332,7 @@ permissions:
|
||||
])
|
||||
expect(Permission.evaluate("read", "README.md", reviewer.permissions).effect).toBe("deny")
|
||||
expect((yield* agents.get(Agent.ID.make("late")))?.permissions).toEqual([
|
||||
...defaultPermissions(global),
|
||||
...defaultPermissions,
|
||||
{ action: "bash", resource: "*", effect: "ask" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
@@ -471,7 +464,6 @@ Use native v2 fields.`,
|
||||
await fs.writeFile(path.join(tmp.path, "modes", "plan.md"), "Make a plan.")
|
||||
})
|
||||
const agents = yield* Agent.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
@@ -489,13 +481,13 @@ Use native v2 fields.`,
|
||||
system: "Review carefully.",
|
||||
description: "Markdown description",
|
||||
request: { body: { temperature: 0.5 } },
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help the team." })
|
||||
expect(yield* agents.get(Agent.ID.make("native"))).toMatchObject({
|
||||
system: "Use native v2 fields.",
|
||||
request: { headers: { "x-agent": "native" }, body: { effort: "high" } },
|
||||
permissions: [...defaultPermissions(global), { action: "edit", resource: "*", effect: "deny" }],
|
||||
permissions: [...defaultPermissions, { action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(yield* agents.get(Agent.ID.make("disabled"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("empty"))).toBeUndefined()
|
||||
|
||||
@@ -508,6 +508,53 @@ describe("ConfigNormalize", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("migrates the legacy thinking block-binding opt-out into model compatibility", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
gateway: {
|
||||
models: {
|
||||
anthropic: { options: { thinking: { type: "adaptive", blockBinding: false }, effort: "high" } },
|
||||
bedrock: { options: { reasoningConfig: { blockBinding: false } } },
|
||||
both: {
|
||||
options: {
|
||||
thinking: { type: "adaptive", blockBinding: false },
|
||||
reasoningConfig: { type: "adaptive", blockBinding: false },
|
||||
},
|
||||
},
|
||||
untouched: { options: { thinking: { type: "adaptive" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.encoded.providers).toMatchObject({
|
||||
gateway: {
|
||||
models: {
|
||||
anthropic: {
|
||||
compatibility: { supportsThinkingBlockBinding: false },
|
||||
settings: { thinking: { type: "adaptive" }, effort: "high" },
|
||||
},
|
||||
bedrock: { compatibility: { supportsThinkingBlockBinding: false }, settings: {} },
|
||||
both: {
|
||||
compatibility: { supportsThinkingBlockBinding: false },
|
||||
settings: { thinking: { type: "adaptive" }, reasoningConfig: { type: "adaptive" } },
|
||||
},
|
||||
untouched: { settings: { thinking: { type: "adaptive" } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "bedrock", "settings", "reasoningConfig"])
|
||||
expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "both", "settings", "thinking", "blockBinding"])
|
||||
expect(result.encoded.providers).not.toHaveProperty([
|
||||
"gateway",
|
||||
"models",
|
||||
"both",
|
||||
"settings",
|
||||
"reasoningConfig",
|
||||
"blockBinding",
|
||||
])
|
||||
expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "untouched", "compatibility"])
|
||||
})
|
||||
|
||||
test("invalid legacy provider overlays skip only that provider", () => {
|
||||
const result = normalized({
|
||||
provider: {
|
||||
|
||||
@@ -637,6 +637,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
supportsEffortUpdates: false,
|
||||
supportsThinkingBlockBinding: false,
|
||||
},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
@@ -735,6 +737,8 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
reasoningField: "vendor_reasoning",
|
||||
maxTokensField: "max_completion_tokens",
|
||||
requireFinishReason: false,
|
||||
supportsEffortUpdates: false,
|
||||
supportsThinkingBlockBinding: false,
|
||||
})
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user