Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8021dbd80f | |||
| c8487bac54 | |||
| 484f00ebf4 | |||
| c3be6c4965 | |||
| 172d08cb98 | |||
| 5e39051183 | |||
| d50b9e8f9a | |||
| 921b1c6a34 |
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -96,7 +96,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -144,7 +144,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -243,7 +243,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -267,7 +267,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -287,7 +287,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -381,7 +381,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -435,7 +435,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -449,7 +449,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -461,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -493,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -559,7 +559,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -592,7 +592,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -666,6 +666,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
@@ -690,7 +691,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -766,7 +767,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -781,7 +782,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -796,7 +797,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||
@@ -841,7 +842,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -854,7 +855,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -888,7 +889,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -907,7 +908,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -949,7 +950,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -976,7 +977,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1027,7 +1028,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1070,9 +1071,9 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch",
|
||||
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
@@ -1674,8 +1675,6 @@
|
||||
|
||||
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="],
|
||||
|
||||
"@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="],
|
||||
@@ -1830,7 +1829,11 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
"@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="],
|
||||
|
||||
"@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0-beta.5", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew=="],
|
||||
|
||||
"@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" } }, "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g=="],
|
||||
|
||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||
|
||||
@@ -3390,7 +3393,7 @@
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
"content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
@@ -3402,8 +3405,6 @@
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
|
||||
|
||||
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||
@@ -3708,8 +3709,6 @@
|
||||
|
||||
"express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
@@ -4094,8 +4093,6 @@
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
@@ -5004,8 +5001,6 @@
|
||||
|
||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
@@ -5954,15 +5949,13 @@
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
"@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
|
||||
"@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
"@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
"@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
@@ -6238,6 +6231,8 @@
|
||||
|
||||
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||
|
||||
"body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
@@ -6320,6 +6315,8 @@
|
||||
|
||||
"execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
|
||||
|
||||
"express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
@@ -6468,8 +6465,6 @@
|
||||
|
||||
"roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||
|
||||
"router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
@@ -6798,58 +6793,28 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
|
||||
"@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
|
||||
"@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
|
||||
|
||||
"@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
|
||||
@@ -6862,14 +6827,10 @@
|
||||
|
||||
"@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
|
||||
|
||||
"@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
@@ -7378,10 +7339,6 @@
|
||||
|
||||
"@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
@@ -7390,20 +7347,14 @@
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
|
||||
|
||||
"@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||
|
||||
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-szhy0258K6IMWi3WRrAzRfhEEIDkGTS04KQwQw+DXyI=",
|
||||
"aarch64-linux": "sha256-eVqUhXGKgx7rmRzpdr9G59Xrxvj2J0Dhx4Q7QRBHKiQ=",
|
||||
"aarch64-darwin": "sha256-r/xAkD/yktfxOVTAArWYb0ZMWe/hEqh80dkYsigRkm8=",
|
||||
"x86_64-darwin": "sha256-xEKSQZM4gYoz9RoaHEvuxrT8AFXHx8hMD1oKGi8lc3Y="
|
||||
"x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=",
|
||||
"aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=",
|
||||
"aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=",
|
||||
"x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -154,8 +154,8 @@
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
@@ -12,9 +13,21 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createUniqueId,
|
||||
For,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
@@ -24,10 +37,10 @@ import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createProviderConnectionController } from "./provider-connection-controller"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const reset = () => setStore("selected", undefined)
|
||||
@@ -371,91 +384,118 @@ function ProviderConnection(props: {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const newLayout = settings.general.newLayoutDesigns
|
||||
const providers = useProviders(() => props.directory?.())
|
||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||
const location = () => {
|
||||
const value = directory()
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
onCleanup(() => {
|
||||
alive.value = false
|
||||
if (timer.current === undefined) return
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(
|
||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||
)
|
||||
const controller = createProviderConnectionController({
|
||||
provider: props.provider,
|
||||
directory,
|
||||
fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"),
|
||||
requestFailed: () => language.t("common.requestFailed"),
|
||||
invalidCode: () => language.t("provider.connect.oauth.code.invalid"),
|
||||
services: {
|
||||
integration: {
|
||||
load: (integrationID, value) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID,
|
||||
location: value ? { directory: value } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
},
|
||||
connection: {
|
||||
key: (integrationID, value, key) =>
|
||||
serverSDK().api.integration.connect.key({
|
||||
integrationID,
|
||||
location: value ? { directory: value } : undefined,
|
||||
key,
|
||||
}),
|
||||
oauth: (integrationID, value, methodID, inputs) =>
|
||||
serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID,
|
||||
methodID,
|
||||
inputs,
|
||||
location: value ? { directory: value } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
status: (integrationID, value, attemptID) =>
|
||||
serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID,
|
||||
attemptID,
|
||||
location: value ? { directory: value } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
complete: (integrationID, value, attemptID, code) =>
|
||||
serverSDK().api.integration.oauth.complete({
|
||||
integrationID,
|
||||
attemptID,
|
||||
location: value ? { directory: value } : undefined,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
provider: {
|
||||
refresh: () =>
|
||||
queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)),
|
||||
},
|
||||
completion: {
|
||||
finish: () => {
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
},
|
||||
},
|
||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
||||
{
|
||||
type: "key" as const,
|
||||
label: language.t("provider.connect.method.apiKey"),
|
||||
},
|
||||
])
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: props.provider, directory: directory() }),
|
||||
(input) =>
|
||||
serverSDK()
|
||||
.api.integration.get({
|
||||
integrationID: input.provider,
|
||||
location: input.directory ? { directory: input.directory } : undefined,
|
||||
})
|
||||
.then((result) => result.data),
|
||||
)
|
||||
const loading = createMemo(() => integration.loading)
|
||||
const methods = createMemo<ConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
return values?.length ? values : fallback()
|
||||
})
|
||||
const loading = controller.data.loading
|
||||
const methods = controller.data.methods
|
||||
const method = controller.data.method
|
||||
const methodIndex = controller.data.methodIndex
|
||||
const authorization = controller.data.authorization
|
||||
const state = controller.auth.state
|
||||
const error = controller.auth.error
|
||||
const connectKey = controller.auth.connectKey
|
||||
const completeCode = controller.auth.completeCode
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as undefined | number,
|
||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
||||
promptInputs: undefined as undefined | Record<string, string>,
|
||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.promptInputs = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.prompt") {
|
||||
draft.state = "prompt"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.inputs") {
|
||||
draft.promptInputs = action.inputs
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
||||
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
@@ -473,7 +513,56 @@ function ProviderConnection(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const selectMethod = controller.auth.select
|
||||
function formatError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = (value as { data?: { message?: unknown } }).data
|
||||
if (typeof data?.message === "string" && data.message) return data.message
|
||||
}
|
||||
if (value && typeof value === "object" && "error" in value) {
|
||||
const nested = formatError((value as { error?: unknown }).error, "")
|
||||
if (nested) return nested
|
||||
}
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = (value as { message?: unknown }).message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (value instanceof Error && value.message) return value.message
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||
if (timer.current !== undefined) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = undefined
|
||||
}
|
||||
|
||||
const method = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
|
||||
if (method.type === "oauth") {
|
||||
if (method.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
await serverSDK()
|
||||
.api.integration.oauth.connect({
|
||||
integrationID: props.provider,
|
||||
methodID: method.id,
|
||||
inputs: inputs ?? {},
|
||||
location: location(),
|
||||
})
|
||||
.then((x) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.complete", authorization: x.data })
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!alive.value) return
|
||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function AuthPromptsView() {
|
||||
const [formStore, setFormStore] = createStore({
|
||||
@@ -494,7 +583,7 @@ function ProviderConnection(props: {
|
||||
const current = createMemo(() => {
|
||||
const all = prompts()
|
||||
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||
if (index === -1) return undefined
|
||||
if (index === -1) return
|
||||
return {
|
||||
index,
|
||||
prompt: all[index],
|
||||
@@ -508,14 +597,13 @@ function ProviderConnection(props: {
|
||||
})
|
||||
|
||||
async function next(index: number, value: Record<string, string>) {
|
||||
const selected = methodIndex()
|
||||
if (selected === undefined) return
|
||||
if (store.methodIndex === undefined) return
|
||||
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||
if (next !== -1) {
|
||||
setFormStore("index", next)
|
||||
return
|
||||
}
|
||||
await selectMethod(selected, value)
|
||||
await selectMethod(store.methodIndex, value)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
@@ -529,12 +617,12 @@ function ProviderConnection(props: {
|
||||
const item = () => current()
|
||||
const text = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "text") return undefined
|
||||
if (!prompt || prompt.type !== "text") return
|
||||
return prompt
|
||||
})
|
||||
const select = createMemo(() => {
|
||||
const prompt = item()?.prompt
|
||||
if (!prompt || prompt.type !== "select") return undefined
|
||||
if (!prompt || prompt.type !== "select") return
|
||||
return prompt
|
||||
})
|
||||
|
||||
@@ -605,9 +693,32 @@ function ProviderConnection(props: {
|
||||
listRef?.onKeyDown(e)
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto) return
|
||||
if (loading()) return
|
||||
if (methods().length === 1) {
|
||||
auto = true
|
||||
void selectMethod(0)
|
||||
}
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
await serverSync()
|
||||
.refreshProviders()
|
||||
.catch(() => undefined)
|
||||
dialog.close()
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (methods().length > 1 && methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
props.onBack()
|
||||
@@ -695,9 +806,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("apiKey")
|
||||
const apiKey = typeof value === "string" ? value : ""
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const apiKey = formData.get("apiKey") as string
|
||||
|
||||
if (!apiKey?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||
@@ -705,7 +816,12 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
await connectKey(apiKey)
|
||||
await serverSDK().api.integration.connect.key({
|
||||
integrationID: props.provider,
|
||||
location: location(),
|
||||
key: apiKey,
|
||||
})
|
||||
await complete()
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -820,9 +936,9 @@ function ProviderConnection(props: {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||
const value = new FormData(e.currentTarget).get("code")
|
||||
const code = typeof value === "string" ? value : ""
|
||||
const form = e.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const code = formData.get("code") as string
|
||||
|
||||
if (!code?.trim()) {
|
||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||
@@ -830,7 +946,20 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
setFormStore("error", undefined)
|
||||
setFormStore("error", await completeCode(code))
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.complete({
|
||||
integrationID: props.provider,
|
||||
attemptID: store.authorization!.attemptID,
|
||||
location: location(),
|
||||
code,
|
||||
})
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (result.ok) {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
||||
}
|
||||
|
||||
if (newLayout())
|
||||
@@ -838,7 +967,7 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<div>
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={authorization()!.url} class="text-v2-text-text-base">
|
||||
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.link")}
|
||||
</Link>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
@@ -877,7 +1006,7 @@ function ProviderConnection(props: {
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||
@@ -903,18 +1032,52 @@ function ProviderConnection(props: {
|
||||
|
||||
function OAuthAutoView() {
|
||||
const code = createMemo(() => {
|
||||
const instructions = authorization()?.instructions
|
||||
const instructions = store.authorization?.instructions
|
||||
if (instructions?.includes(":")) {
|
||||
return instructions.split(":").pop()?.trim()
|
||||
}
|
||||
return instructions
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
const poll = async () => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization || !alive.value) return
|
||||
const result = await serverSDK()
|
||||
.api.integration.oauth.status({
|
||||
integrationID: props.provider,
|
||||
attemptID: authorization.attemptID,
|
||||
location: location(),
|
||||
})
|
||||
.then((value) => ({ ok: true as const, status: value.data }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!alive.value) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await complete()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
timer.current = setTimeout(poll, 1_000)
|
||||
}
|
||||
void poll()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="text-14-regular text-text-base">
|
||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||
</div>
|
||||
<TextField
|
||||
@@ -958,7 +1121,7 @@ function ProviderConnection(props: {
|
||||
<div
|
||||
onKeyDown={handleKey}
|
||||
tabIndex={newLayout() ? undefined : 0}
|
||||
autofocus={!newLayout() && methodIndex() === undefined ? true : undefined}
|
||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
@@ -969,10 +1132,10 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={methodIndex() === undefined}>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={state() === "pending"}>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
@@ -980,14 +1143,14 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={state() === "prompt"}>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={state() === "error"}>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: error() ?? "" })}</span>
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
@@ -996,10 +1159,10 @@ function ProviderConnection(props: {
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={authorization()?.mode === "code"}>
|
||||
<Match when={store.authorization?.mode === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={authorization()?.mode === "auto"}>
|
||||
<Match when={store.authorization?.mode === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -6,17 +6,22 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Show } from "solid-js"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import {
|
||||
type ServerDomainController,
|
||||
type ServerFormController,
|
||||
useServerDomainController,
|
||||
useServerFormController,
|
||||
} from "@/components/server/server-management-controller"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
interface ServerFormProps {
|
||||
value: string
|
||||
@@ -35,6 +40,76 @@ interface ServerFormProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey: () => defaultKey.latest, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
const looksComplete = (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return false
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return false
|
||||
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
|
||||
return host.includes(".") || host.includes(":")
|
||||
}
|
||||
|
||||
const previewStatus = async (
|
||||
value: string,
|
||||
username: string,
|
||||
password: string,
|
||||
setStatus: (value: boolean | undefined) => void,
|
||||
) => {
|
||||
setStatus(undefined)
|
||||
if (!looksComplete(value)) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) return
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (username) http.username = username
|
||||
if (password) http.password = password
|
||||
const result = await checkServerHealth(http)
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { previewStatus }
|
||||
}
|
||||
|
||||
function ServerForm(props: ServerFormProps) {
|
||||
const language = useLanguage()
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
@@ -102,40 +177,400 @@ function ServerForm(props: ServerFormProps) {
|
||||
|
||||
export function DialogSelectServer() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const domain = useServerDomainController({ onSelect: () => dialog.close() })
|
||||
const form = useServerFormController({ onSelect: () => dialog.close() })
|
||||
const title = () => {
|
||||
if (!form.state.open()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={form.reset} aria-label={language.t("common.goBack")} />
|
||||
<span>
|
||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const controller = useServerManagementController({ onSelect: dialog.close })
|
||||
|
||||
return (
|
||||
<Dialog title={title()}>
|
||||
<Dialog title={controller.formTitle()}>
|
||||
<div class="flex flex-1 min-h-0 flex-col px-5">
|
||||
<Show
|
||||
when={form.state.open()}
|
||||
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
|
||||
>
|
||||
<ServerConnectionForm form={form} />
|
||||
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
</Show>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: {
|
||||
domain: ServerDomainController
|
||||
onAdd: () => void
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
}) {
|
||||
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
editServer: {
|
||||
id: undefined as string | undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdd = () => {
|
||||
setStore("addServer", {
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
showForm: false,
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const resetEdit = () => {
|
||||
setStore("editServer", {
|
||||
id: undefined,
|
||||
value: "",
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const addMutation = useMutation(() => ({
|
||||
mutationFn: async (value: string) => {
|
||||
const normalized = normalizeServerUrl(value)
|
||||
if (!normalized) {
|
||||
resetAdd()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
http: { url: normalized },
|
||||
}
|
||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
|
||||
resetAdd()
|
||||
if (options.navigateOnAdd === false) {
|
||||
server.add(conn)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
await select(conn, true)
|
||||
},
|
||||
}))
|
||||
|
||||
const editMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||
if (input.original.type !== "http") return
|
||||
const normalized = normalizeServerUrl(input.value)
|
||||
if (!normalized) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const name = store.editServer.name.trim() || undefined
|
||||
const username = store.editServer.username || undefined
|
||||
const password = store.editServer.password || undefined
|
||||
const existingName = input.original.displayName
|
||||
if (
|
||||
normalized === input.original.http.url &&
|
||||
name === existingName &&
|
||||
username === input.original.http.username &&
|
||||
password === input.original.http.password
|
||||
) {
|
||||
resetEdit()
|
||||
return
|
||||
}
|
||||
|
||||
const conn: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: { url: normalized, username, password },
|
||||
}
|
||||
const result = await checkServerHealth(conn.http)
|
||||
if (!result.healthy) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||
return
|
||||
}
|
||||
if (normalized === input.original.http.url) {
|
||||
server.add(conn)
|
||||
} else {
|
||||
replaceServer(input.original, conn)
|
||||
}
|
||||
|
||||
resetEdit()
|
||||
},
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = server.key
|
||||
tabs.removeServer(originalKey)
|
||||
const newConn = server.add(next)
|
||||
if (!newConn) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
|
||||
if (nextActive) server.setActive(nextActive)
|
||||
server.remove(originalKey)
|
||||
}
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((x) => x !== current)]
|
||||
})
|
||||
|
||||
const settings = useSettings()
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]),
|
||||
)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && global.servers.health[ServerConnection.key(conn)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigate("/")
|
||||
return
|
||||
}
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||
}
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { url: value, error: "" })
|
||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddNameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleAddUsernameChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { username: value, error: "" })
|
||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddPasswordChange = (value: string) => {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { password: value, error: "" })
|
||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||
setStore("addServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { value, error: "" })
|
||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { name: value, error: "" })
|
||||
}
|
||||
|
||||
const handleEditUsernameChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { username: value, error: "" })
|
||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const handleEditPasswordChange = (value: string) => {
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { password: value, error: "" })
|
||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||
setStore("editServer", { status: next }),
|
||||
)
|
||||
}
|
||||
|
||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||
if (store.editServer.id) return "edit"
|
||||
if (store.addServer.showForm) return "add"
|
||||
return "list"
|
||||
})
|
||||
|
||||
const editing = createMemo(() => {
|
||||
if (!store.editServer.id) return
|
||||
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const startAdd = () => {
|
||||
resetEdit()
|
||||
setStore("addServer", {
|
||||
showForm: true,
|
||||
url: "",
|
||||
name: "",
|
||||
username: DEFAULT_USERNAME,
|
||||
password: "",
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const startEdit = (conn: ServerConnection.Http) => {
|
||||
resetAdd()
|
||||
setStore("editServer", {
|
||||
id: conn.http.url,
|
||||
value: conn.http.url,
|
||||
name: conn.displayName ?? "",
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(conn)]?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
if (mode() === "add") {
|
||||
if (addMutation.isPending) return
|
||||
setStore("addServer", { error: "" })
|
||||
addMutation.mutate(store.addServer.url)
|
||||
return
|
||||
}
|
||||
const original = editing()
|
||||
if (!original) return
|
||||
if (editMutation.isPending) return
|
||||
setStore("editServer", { error: "" })
|
||||
editMutation.mutate({ original, value: store.editServer.value })
|
||||
}
|
||||
|
||||
const isFormMode = createMemo(() => mode() !== "list")
|
||||
const isAddMode = createMemo(() => mode() === "add")
|
||||
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||
|
||||
const formTitle = createMemo(() => {
|
||||
if (!isFormMode()) return language.t("dialog.server.title")
|
||||
return (
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editServer.id) return
|
||||
if (editing()) return
|
||||
resetEdit()
|
||||
})
|
||||
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
await setDefault(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
defaultKey,
|
||||
canDefault,
|
||||
current,
|
||||
sortedItems,
|
||||
status: () => global.servers.health,
|
||||
isFormMode,
|
||||
isAddMode,
|
||||
formTitle,
|
||||
formBusy,
|
||||
formValue: () => (isAddMode() ? store.addServer.url : store.editServer.value),
|
||||
formName: () => (isAddMode() ? store.addServer.name : store.editServer.name),
|
||||
formUsername: () => (isAddMode() ? store.addServer.username : store.editServer.username),
|
||||
formPassword: () => (isAddMode() ? store.addServer.password : store.editServer.password),
|
||||
formError: () => (isAddMode() ? store.addServer.error : store.editServer.error),
|
||||
formStatus: () => (isAddMode() ? store.addServer.status : store.editServer.status),
|
||||
select,
|
||||
setDefault,
|
||||
startAdd,
|
||||
startEdit,
|
||||
resetForm,
|
||||
submitForm,
|
||||
handleRemove,
|
||||
handleFormChange: () => (isAddMode() ? handleAddChange : handleEditChange),
|
||||
handleFormNameChange: () => (isAddMode() ? handleAddNameChange : handleEditNameChange),
|
||||
handleFormUsernameChange: () => (isAddMode() ? handleAddUsernameChange : handleEditUsernameChange),
|
||||
handleFormPasswordChange: () => (isAddMode() ? handleAddPasswordChange : handleEditPasswordChange),
|
||||
}
|
||||
}
|
||||
|
||||
export function ServerConnectionList(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -149,10 +584,10 @@ export function ServerConnectionList(props: {
|
||||
}}
|
||||
noInitialSelection
|
||||
emptyMessage={language.t("dialog.server.empty")}
|
||||
items={props.domain.collection.items}
|
||||
items={props.controller.sortedItems}
|
||||
key={(x) => x.http.url}
|
||||
onSelect={(x) => {
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
|
||||
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
|
||||
}}
|
||||
divider={true}
|
||||
>
|
||||
@@ -161,15 +596,15 @@ export function ServerConnectionList(props: {
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-center w-5">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={props.domain.collection.health()[key]?.healthy === false}
|
||||
status={props.domain.collection.health()[key]}
|
||||
dimmed={props.controller.status()[key]?.healthy === false}
|
||||
status={props.controller.status()[key]}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={props.domain.defaults.key() === ServerConnection.key(i)}>
|
||||
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
|
||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||
{language.t("dialog.server.status.default")}
|
||||
</span>
|
||||
@@ -178,12 +613,7 @@ export function ServerConnectionList(props: {
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-4 pl-4">
|
||||
<Show
|
||||
when={
|
||||
props.domain.collection.current() &&
|
||||
ServerConnection.key(props.domain.collection.current()!) === key
|
||||
}
|
||||
>
|
||||
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
@@ -202,18 +632,18 @@ export function ServerConnectionList(props: {
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (i.type !== "http") return
|
||||
props.onEdit(i)
|
||||
props.controller.startEdit(i)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
@@ -221,7 +651,7 @@ export function ServerConnectionList(props: {
|
||||
</Show>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => props.domain.connection.remove(ServerConnection.key(i))}
|
||||
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||
@@ -241,7 +671,7 @@ export function ServerConnectionList(props: {
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
size="large"
|
||||
onClick={props.onAdd}
|
||||
onClick={props.controller.startAdd}
|
||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||
>
|
||||
{language.t("dialog.server.add.button")}
|
||||
@@ -251,38 +681,38 @@ export function ServerConnectionList(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function ServerConnectionForm(props: { form: ServerFormController }) {
|
||||
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4">
|
||||
<ServerForm
|
||||
value={props.form.state.value()}
|
||||
name={props.form.state.name()}
|
||||
username={props.form.state.username()}
|
||||
password={props.form.state.password()}
|
||||
value={props.controller.formValue()}
|
||||
name={props.controller.formName()}
|
||||
username={props.controller.formUsername()}
|
||||
password={props.controller.formPassword()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
busy={props.form.state.busy()}
|
||||
error={props.form.state.error()}
|
||||
status={props.form.state.status()}
|
||||
onChange={props.form.change.value}
|
||||
onNameChange={props.form.change.name}
|
||||
onUsernameChange={props.form.change.username}
|
||||
onPasswordChange={props.form.change.password}
|
||||
onSubmit={props.form.submit}
|
||||
onBack={props.form.reset}
|
||||
busy={props.controller.formBusy()}
|
||||
error={props.controller.formError()}
|
||||
status={props.controller.formStatus()}
|
||||
onChange={props.controller.handleFormChange()}
|
||||
onNameChange={props.controller.handleFormNameChange()}
|
||||
onUsernameChange={props.controller.handleFormUsernameChange()}
|
||||
onPasswordChange={props.controller.handleFormPasswordChange()}
|
||||
onSubmit={props.controller.submitForm}
|
||||
onBack={props.controller.resetForm}
|
||||
/>
|
||||
<div class="shrink-0 pb-5">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
onClick={props.form.submit}
|
||||
disabled={props.form.state.busy()}
|
||||
onClick={props.controller.submitForm}
|
||||
disabled={props.controller.formBusy()}
|
||||
class="px-3 py-1.5"
|
||||
>
|
||||
{props.form.state.busy()
|
||||
{props.controller.formBusy()
|
||||
? language.t("dialog.server.add.checking")
|
||||
: props.form.state.adding()
|
||||
: props.controller.isAddMode()
|
||||
? language.t("dialog.server.add.button")
|
||||
: language.t("common.save")}
|
||||
</Button>
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, expect, test, vi } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import {
|
||||
createProviderConnectionWorkflowController,
|
||||
type ProviderConnectMethod,
|
||||
} from "./provider-connection-controller"
|
||||
|
||||
const authorization = {
|
||||
attemptID: "attempt-1",
|
||||
url: "https://example.com/auth",
|
||||
instructions: "Code: ABCD",
|
||||
mode: "auto" as const,
|
||||
time: { created: 0, expires: 1 },
|
||||
}
|
||||
|
||||
function services(options: {
|
||||
methods: readonly ProviderConnectMethod[]
|
||||
status?: () => Promise<{ status: "pending" | "complete" }>
|
||||
refresh?: () => void
|
||||
finish?: () => void
|
||||
}) {
|
||||
return {
|
||||
connection: {
|
||||
key: async () => undefined,
|
||||
oauth: async () => authorization,
|
||||
status: options.status ?? (async () => ({ status: "pending" as const })),
|
||||
complete: async () => undefined,
|
||||
},
|
||||
provider: { refresh: async () => options.refresh?.() },
|
||||
completion: { finish: options.finish ?? (() => undefined) },
|
||||
}
|
||||
}
|
||||
|
||||
function create(options: Parameters<typeof services>[0]) {
|
||||
return createRoot((dispose) => ({
|
||||
dispose,
|
||||
controller: createProviderConnectionWorkflowController({
|
||||
provider: "example",
|
||||
directory: () => "/project",
|
||||
requestFailed: () => "Request failed",
|
||||
invalidCode: () => "Invalid code",
|
||||
loading: () => false,
|
||||
methods: () => [...options.methods],
|
||||
services: services(options),
|
||||
pollInterval: 100,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe("provider connection controller", () => {
|
||||
test("moves prompted OAuth methods through prompt and authorization states", async () => {
|
||||
const owned = create({
|
||||
methods: [
|
||||
{
|
||||
id: "oauth",
|
||||
type: "oauth",
|
||||
label: "OAuth",
|
||||
prompts: [{ type: "text", key: "account", message: "Account" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await owned.controller.auth.select(0)
|
||||
expect(owned.controller.auth.state()).toBe("prompt")
|
||||
|
||||
await owned.controller.auth.select(0, { account: "team" })
|
||||
expect(owned.controller.auth.state()).toBe("complete")
|
||||
expect(owned.controller.data.authorization()).toEqual(authorization)
|
||||
owned.dispose()
|
||||
})
|
||||
|
||||
test("polls auto OAuth independently and completes after provider refresh", async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const calls: string[] = []
|
||||
const statuses = [{ status: "pending" as const }, { status: "complete" as const }]
|
||||
const owned = create({
|
||||
methods: [{ id: "oauth", type: "oauth", label: "OAuth" }],
|
||||
status: async () => statuses.shift() ?? { status: "complete" as const },
|
||||
refresh: () => calls.push("refresh"),
|
||||
finish: () => calls.push("finish"),
|
||||
})
|
||||
|
||||
await owned.controller.auth.select(0)
|
||||
await settle()
|
||||
expect(owned.controller.data.authorization()).toEqual(authorization)
|
||||
expect(calls).toEqual([])
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await settle()
|
||||
expect(calls).toEqual(["refresh", "finish"])
|
||||
owned.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels an owned poll timer on reset and disposal", async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const status = vi.fn(async () => ({ status: "pending" as const }))
|
||||
const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status })
|
||||
|
||||
await owned.controller.auth.select(0)
|
||||
expect(status).toHaveBeenCalledTimes(1)
|
||||
|
||||
owned.controller.auth.reset()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await settle()
|
||||
expect(status).toHaveBeenCalledTimes(1)
|
||||
|
||||
await owned.controller.auth.select(0)
|
||||
expect(status).toHaveBeenCalledTimes(2)
|
||||
owned.dispose()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await settle()
|
||||
expect(status).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,254 +0,0 @@
|
||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||
type OAuthStatus = { status: "pending" | "complete" | "expired" } | { status: "failed"; message: string }
|
||||
|
||||
type ProviderConnectionServices = {
|
||||
integration: {
|
||||
load: (provider: string, directory?: string) => Promise<{ methods: readonly IntegrationMethod[] } | null>
|
||||
}
|
||||
connection: {
|
||||
key: (provider: string, directory: string | undefined, key: string) => Promise<unknown>
|
||||
oauth: (
|
||||
provider: string,
|
||||
directory: string | undefined,
|
||||
method: string,
|
||||
inputs: Record<string, string>,
|
||||
) => Promise<Authorization>
|
||||
status: (provider: string, directory: string | undefined, attempt: string) => Promise<OAuthStatus>
|
||||
complete: (provider: string, directory: string | undefined, attempt: string, code: string) => Promise<unknown>
|
||||
}
|
||||
provider: { refresh: () => Promise<unknown> }
|
||||
completion: { finish: () => void }
|
||||
}
|
||||
|
||||
export function createProviderConnectionController(options: {
|
||||
provider: string
|
||||
directory: () => string | undefined
|
||||
fallbackKeyLabel: () => string
|
||||
requestFailed: () => string
|
||||
invalidCode: () => string
|
||||
services: ProviderConnectionServices
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const [integration] = createResource(
|
||||
() => ({ provider: options.provider, directory: options.directory() }),
|
||||
(input) => options.services.integration.load(input.provider, input.directory),
|
||||
)
|
||||
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||
const values = integration.latest?.methods.filter(
|
||||
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
|
||||
)
|
||||
if (values?.length) return [...values]
|
||||
return [{ type: "key", label: options.fallbackKeyLabel() }]
|
||||
})
|
||||
return createProviderConnectionWorkflowController({
|
||||
...options,
|
||||
loading: () => integration.loading,
|
||||
methods,
|
||||
})
|
||||
}
|
||||
|
||||
export function createProviderConnectionWorkflowController(options: {
|
||||
provider: string
|
||||
directory: () => string | undefined
|
||||
requestFailed: () => string
|
||||
invalidCode: () => string
|
||||
loading: () => boolean
|
||||
methods: () => ProviderConnectMethod[]
|
||||
services: Pick<ProviderConnectionServices, "connection" | "provider" | "completion">
|
||||
pollInterval?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
methodIndex: undefined as number | undefined,
|
||||
authorization: undefined as Authorization | undefined,
|
||||
state: "pending" as "pending" | "complete" | "error" | "prompt" | undefined,
|
||||
error: undefined as string | undefined,
|
||||
})
|
||||
const polling = {
|
||||
generation: 0,
|
||||
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||
disposed: false,
|
||||
}
|
||||
const methods = options.methods
|
||||
const method = createMemo(() => (store.methodIndex === undefined ? undefined : methods().at(store.methodIndex)))
|
||||
|
||||
type Action =
|
||||
| { type: "method.select"; index: number }
|
||||
| { type: "method.reset" }
|
||||
| { type: "auth.prompt" }
|
||||
| { type: "auth.pending" }
|
||||
| { type: "auth.complete"; authorization: Authorization }
|
||||
| { type: "auth.error"; error: string }
|
||||
|
||||
const dispatch = (action: Action) => {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
if (action.type === "method.select") {
|
||||
draft.methodIndex = action.index
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "method.reset") {
|
||||
draft.methodIndex = undefined
|
||||
draft.authorization = undefined
|
||||
draft.state = undefined
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.prompt") {
|
||||
draft.state = "prompt"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.pending") {
|
||||
draft.state = "pending"
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
if (action.type === "auth.complete") {
|
||||
draft.state = "complete"
|
||||
draft.authorization = action.authorization
|
||||
draft.error = undefined
|
||||
return
|
||||
}
|
||||
draft.state = "error"
|
||||
draft.error = action.error
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const cancelPolling = () => {
|
||||
polling.generation++
|
||||
if (polling.timer === undefined) return
|
||||
clearTimeout(polling.timer)
|
||||
polling.timer = undefined
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
await options.services.provider.refresh().catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.services.completion.finish()
|
||||
}
|
||||
const poll = async (authorization: Authorization, generation: number) => {
|
||||
const result = await options.services.connection
|
||||
.status(options.provider, options.directory(), authorization.attemptID)
|
||||
.then((status) => ({ ok: true as const, status }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "complete") {
|
||||
await finish()
|
||||
return
|
||||
}
|
||||
if (result.status.status === "failed") {
|
||||
dispatch({ type: "auth.error", error: result.status.message })
|
||||
return
|
||||
}
|
||||
if (result.status.status === "expired") {
|
||||
dispatch({ type: "auth.error", error: options.requestFailed() })
|
||||
return
|
||||
}
|
||||
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||
}
|
||||
const select = async (index: number, inputs?: Record<string, string>) => {
|
||||
cancelPolling()
|
||||
const generation = polling.generation
|
||||
const selected = methods()[index]
|
||||
dispatch({ type: "method.select", index })
|
||||
if (selected.type !== "oauth") return
|
||||
if (selected.prompts?.length && !inputs) {
|
||||
dispatch({ type: "auth.prompt" })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.pending" })
|
||||
const result = await options.services.connection
|
||||
.oauth(options.provider, options.directory(), selected.id, inputs ?? {})
|
||||
.then((authorization) => ({ ok: true as const, authorization }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (polling.disposed || generation !== polling.generation) return
|
||||
if (!result.ok) {
|
||||
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
|
||||
return
|
||||
}
|
||||
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||
}
|
||||
const reset = () => {
|
||||
cancelPolling()
|
||||
dispatch({ type: "method.reset" })
|
||||
}
|
||||
const connectKey = async (key: string) => {
|
||||
await options.services.connection.key(options.provider, options.directory(), key)
|
||||
await finish()
|
||||
}
|
||||
const completeCode = async (code: string) => {
|
||||
const authorization = store.authorization
|
||||
if (!authorization) return options.invalidCode()
|
||||
const result = await options.services.connection
|
||||
.complete(options.provider, options.directory(), authorization.attemptID, code)
|
||||
.then(() => ({ ok: true as const }))
|
||||
.catch((error) => ({ ok: false as const, error }))
|
||||
if (!result.ok) return formatProviderConnectionError(result.error, options.invalidCode())
|
||||
await finish()
|
||||
return undefined
|
||||
}
|
||||
|
||||
let auto = false
|
||||
createEffect(() => {
|
||||
if (auto || options.loading() || methods().length !== 1) return
|
||||
auto = true
|
||||
void select(0)
|
||||
})
|
||||
onCleanup(() => {
|
||||
polling.disposed = true
|
||||
cancelPolling()
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
loading: options.loading,
|
||||
methods,
|
||||
method,
|
||||
methodIndex: () => store.methodIndex,
|
||||
authorization: () => store.authorization,
|
||||
},
|
||||
auth: {
|
||||
state: () => store.state,
|
||||
error: () => store.error,
|
||||
select,
|
||||
reset,
|
||||
connectKey,
|
||||
completeCode,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
|
||||
|
||||
export function formatProviderConnectionError(value: unknown, fallback: string): string {
|
||||
if (value && typeof value === "object" && "data" in value) {
|
||||
const data = value.data
|
||||
if (data && typeof data === "object" && "message" in data && typeof data.message === "string" && data.message)
|
||||
return data.message
|
||||
}
|
||||
if (value && typeof value === "object" && "error" in value) {
|
||||
const nested = formatProviderConnectionError(value.error, "")
|
||||
if (nested) return nested
|
||||
}
|
||||
if (value && typeof value === "object" && "message" in value) {
|
||||
const message = value.message
|
||||
if (typeof message === "string" && message) return message
|
||||
}
|
||||
if (value instanceof Error && value.message) return value.message
|
||||
if (typeof value === "string" && value) return value
|
||||
return fallback
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { detectServerProtocol } from "@/utils/server-protocol"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
const DEFAULT_USERNAME = "opencode"
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultKeyActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
return (await platform.getDefaultServer?.()) ?? null
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const set = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultKeyActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: () => defaultKey.latest,
|
||||
available: createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer),
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
function useServerMutations() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
|
||||
return {
|
||||
add: (connection: ServerConnection.Http) => server.add(connection),
|
||||
replace: (original: ServerConnection.Http, next: ServerConnection.Http) =>
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => server.key,
|
||||
removeTabs: (key) => tabs.removeServer(key),
|
||||
add: (connection) => server.add(connection),
|
||||
setActive: (key) => server.setActive(key),
|
||||
remove: (key) => server.remove(key),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerActionsController() {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const defaults = useDefaultServer()
|
||||
|
||||
const remove = async (key: ServerConnection.Key) => {
|
||||
try {
|
||||
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
|
||||
tabs.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) await defaults.set(null)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaults, connection: { remove } }
|
||||
}
|
||||
|
||||
export type ServerActionsController = ReturnType<typeof useServerActionsController>
|
||||
|
||||
export function useServerCollectionController() {
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const settings = useSettings()
|
||||
const actions = useServerActionsController()
|
||||
|
||||
const items = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (!list.includes(current)) return [current, ...list]
|
||||
return [current, ...list.filter((item) => item !== current)]
|
||||
})
|
||||
const current = createMemo<ServerConnection.Any | undefined>(() =>
|
||||
settings.general.newLayoutDesigns()
|
||||
? undefined
|
||||
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
|
||||
)
|
||||
const sorted = createMemo(() => {
|
||||
const raw = items()
|
||||
const list = settings.general.newLayoutDesigns()
|
||||
? raw
|
||||
: raw.filter((item) => global.ensureServerCtx(item).sdk.protocolKind() !== "v2")
|
||||
if (!list.length) return list
|
||||
const active = current()
|
||||
const order = new Map(list.map((item, index) => [item, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff =
|
||||
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
collection: {
|
||||
items: sorted,
|
||||
current,
|
||||
health: () => global.servers.health,
|
||||
},
|
||||
...actions,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
|
||||
|
||||
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const collection = useServerCollectionController()
|
||||
|
||||
const select = async (connection: ServerConnection.Any) => {
|
||||
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
|
||||
}
|
||||
|
||||
return { ...collection, selection: { select } }
|
||||
}
|
||||
|
||||
export type ServerDomainController = ReturnType<typeof useServerDomainController>
|
||||
|
||||
export function useServerFormController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const mutations = useServerMutations()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const healthPreview = createServerHealthPreview(checkServerHealth)
|
||||
const [store, setStore] = createStore({
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
onCleanup(healthPreview.cancel)
|
||||
|
||||
const reset = () => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", username: DEFAULT_USERNAME, password: "" },
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
}
|
||||
const allServers = () => {
|
||||
if (!server.current || server.list.includes(server.current)) return server.list
|
||||
return [server.current, ...server.list]
|
||||
}
|
||||
const editing = createMemo(() =>
|
||||
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
|
||||
)
|
||||
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const normalized = normalizeServerUrl(store.values.url)
|
||||
if (!normalized) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const original = store.mode === "edit" ? editing() : undefined
|
||||
if (store.mode === "edit" && !original) return
|
||||
const name = store.values.name.trim() || undefined
|
||||
const username = store.values.username || undefined
|
||||
const password = store.values.password || undefined
|
||||
if (
|
||||
original?.type === "http" &&
|
||||
normalized === original.http.url &&
|
||||
name === original.displayName &&
|
||||
username === original.http.username &&
|
||||
password === original.http.password
|
||||
) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const connection: ServerConnection.Http = {
|
||||
type: "http",
|
||||
displayName: name,
|
||||
http: {
|
||||
url: normalized,
|
||||
username: store.mode === "add" && !password ? undefined : username,
|
||||
password,
|
||||
},
|
||||
}
|
||||
const result = await checkServerHealth(connection.http)
|
||||
if (!result.healthy) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
if (
|
||||
!settings.general.newLayoutDesigns() &&
|
||||
(await detectServerProtocol(connection.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||
) {
|
||||
setStore("error", language.t("dialog.server.add.error"))
|
||||
return
|
||||
}
|
||||
|
||||
if (original?.type === "http") {
|
||||
if (normalized === original.http.url) mutations.add(connection)
|
||||
if (normalized !== original.http.url) mutations.replace(original, connection)
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
reset()
|
||||
if (options.navigateOnAdd === false) {
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
return
|
||||
}
|
||||
mutations.add(connection)
|
||||
options.onSelect?.()
|
||||
navigate("/")
|
||||
},
|
||||
}))
|
||||
|
||||
const preview = () => void healthPreview.preview(store.values, (status) => setStore("status", status))
|
||||
const change = (field: keyof ServerFormValues, value: string) => {
|
||||
if (request.isPending) return
|
||||
setStore("values", field, value)
|
||||
setStore("error", "")
|
||||
if (field !== "name") preview()
|
||||
}
|
||||
const startAdd = () => {
|
||||
reset()
|
||||
setStore("mode", "add")
|
||||
}
|
||||
const startEdit = (connection: ServerConnection.Http) => {
|
||||
reset()
|
||||
setStore({
|
||||
mode: "edit",
|
||||
originalUrl: connection.http.url,
|
||||
values: {
|
||||
url: connection.http.url,
|
||||
name: connection.displayName ?? "",
|
||||
username: connection.http.username ?? "",
|
||||
password: connection.http.password ?? "",
|
||||
},
|
||||
error: "",
|
||||
status: global.servers.health[ServerConnection.key(connection)]?.healthy,
|
||||
})
|
||||
}
|
||||
const submit = () => {
|
||||
if (store.mode === "list" || request.isPending) return
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
if (editing()) return
|
||||
reset()
|
||||
})
|
||||
|
||||
return {
|
||||
state: {
|
||||
mode: () => store.mode,
|
||||
open: () => store.mode !== "list",
|
||||
adding: () => store.mode === "add",
|
||||
busy: () => request.isPending,
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
username: () => store.values.username,
|
||||
password: () => store.values.password,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
change: {
|
||||
value: (value: string) => change("url", value),
|
||||
name: (value: string) => change("name", value),
|
||||
username: (value: string) => change("username", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
|
||||
export type ServerFormController = ReturnType<typeof useServerFormController>
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const values = (url: string): ServerFormValues => ({ url, name: "", username: "opencode", password: "" })
|
||||
|
||||
describe("createServerHealthPreview", () => {
|
||||
test("ignores an older response that resolves after the latest response", async () => {
|
||||
const first = deferred<{ healthy: boolean }>()
|
||||
const second = deferred<{ healthy: boolean }>()
|
||||
const requests = [first, second]
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => requests.shift()!.promise)
|
||||
|
||||
const older = preview.preview(values("old.example.com"), (value) => status.push(value))
|
||||
const latest = preview.preview(values("new.example.com"), (value) => status.push(value))
|
||||
second.resolve({ healthy: true })
|
||||
await latest
|
||||
first.resolve({ healthy: false })
|
||||
await older
|
||||
|
||||
expect(status).toEqual([undefined, undefined, true])
|
||||
})
|
||||
|
||||
test("an incomplete value invalidates an in-flight response", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
await preview.preview(values("server"), (value) => status.push(value))
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined, undefined])
|
||||
})
|
||||
|
||||
test("cancellation prevents an in-flight response from updating status", async () => {
|
||||
const request = deferred<{ healthy: boolean }>()
|
||||
const status: Array<boolean | undefined> = []
|
||||
const preview = createServerHealthPreview(() => request.promise)
|
||||
|
||||
const pending = preview.preview(values("server.example.com"), (value) => status.push(value))
|
||||
preview.cancel()
|
||||
request.resolve({ healthy: true })
|
||||
await pending
|
||||
|
||||
expect(status).toEqual([undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe("replaceServerConnection", () => {
|
||||
const original: ServerConnection.Http = { type: "http", http: { url: "https://old.example.com" } }
|
||||
const next: ServerConnection.Http = { type: "http", http: { url: "https://new.example.com" } }
|
||||
|
||||
test("moves active selection after adding the replacement and removes the original", () => {
|
||||
const calls: string[] = []
|
||||
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: (key) => calls.push(`tabs:${key}`),
|
||||
add: (server) => {
|
||||
calls.push(`add:${ServerConnection.key(server)}`)
|
||||
return server
|
||||
},
|
||||
setActive: (key) => calls.push(`active:${key}`),
|
||||
remove: (key) => calls.push(`remove:${key}`),
|
||||
})
|
||||
|
||||
expect(calls).toEqual([
|
||||
"tabs:https://old.example.com",
|
||||
"add:https://new.example.com",
|
||||
"active:https://new.example.com",
|
||||
"remove:https://old.example.com",
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original when the replacement cannot be added", () => {
|
||||
const removed: ServerConnection.Key[] = []
|
||||
|
||||
replaceServerConnection(original, next, {
|
||||
active: () => ServerConnection.key(original),
|
||||
removeTabs: () => {},
|
||||
add: () => undefined,
|
||||
setActive: () => {},
|
||||
remove: (key) => removed.push(key),
|
||||
})
|
||||
|
||||
expect(removed).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||
import type { ServerHealth } from "@/utils/server-health"
|
||||
|
||||
export type ServerFormValues = {
|
||||
url: string
|
||||
name: string
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function createServerHealthPreview(
|
||||
check: (server: ServerConnection.HttpBase) => Promise<Pick<ServerHealth, "healthy">>,
|
||||
) {
|
||||
let generation = 0
|
||||
|
||||
const cancel = () => {
|
||||
generation += 1
|
||||
}
|
||||
|
||||
const preview = async (values: ServerFormValues, setStatus: (value: boolean | undefined) => void) => {
|
||||
const current = ++generation
|
||||
setStatus(undefined)
|
||||
const normalized = normalizeServerUrl(values.url)
|
||||
if (!normalized) return
|
||||
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
|
||||
if (!host) return
|
||||
if (!host.includes("localhost") && !host.startsWith("127.0.0.1") && !host.includes(".") && !host.includes(":"))
|
||||
return
|
||||
|
||||
const http: ServerConnection.HttpBase = { url: normalized }
|
||||
if (values.username) http.username = values.username
|
||||
if (values.password) http.password = values.password
|
||||
const result = await check(http)
|
||||
if (current !== generation) return
|
||||
setStatus(result.healthy)
|
||||
}
|
||||
|
||||
return { cancel, preview }
|
||||
}
|
||||
|
||||
export function replaceServerConnection(
|
||||
original: ServerConnection.Http,
|
||||
next: ServerConnection.Http,
|
||||
operations: {
|
||||
active: () => ServerConnection.Key | undefined
|
||||
removeTabs: (key: ServerConnection.Key) => void
|
||||
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
|
||||
setActive: (key: ServerConnection.Key) => void
|
||||
remove: (key: ServerConnection.Key) => void
|
||||
},
|
||||
) {
|
||||
const originalKey = ServerConnection.key(original)
|
||||
const active = operations.active()
|
||||
operations.removeTabs(originalKey)
|
||||
const added = operations.add(next)
|
||||
if (!added) return
|
||||
const nextActive = active === originalKey ? ServerConnection.key(added) : active
|
||||
if (nextActive) operations.setActive(nextActive)
|
||||
operations.remove(originalKey)
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import type { ServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
|
||||
export const ServerRowMenu: Component<{
|
||||
server: ServerConnection.Any
|
||||
domain: ServerActionsController
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
onEdit: (server: ServerConnection.Http) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
@@ -19,12 +19,12 @@ export const ServerRowMenu: Component<{
|
||||
<ServerRowMenuView
|
||||
server={props.server}
|
||||
labels={serverMenuLabels(language)}
|
||||
canDefault={props.domain.defaults.available()}
|
||||
isDefault={props.domain.defaults.key() === key}
|
||||
canDefault={props.controller.canDefault()}
|
||||
isDefault={props.controller.defaultKey() === key}
|
||||
onEdit={props.onEdit}
|
||||
onSetDefault={() => props.domain.defaults.set(key)}
|
||||
onRemoveDefault={() => props.domain.defaults.set(null)}
|
||||
onRemove={() => props.domain.connection.remove(key)}
|
||||
onSetDefault={() => props.controller.setDefault(key)}
|
||||
onRemoveDefault={() => props.controller.setDefault(null)}
|
||||
onRemove={() => props.controller.handleRemove(key)}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
/>
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Show, type Component } from "solid-js"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerDomainController, useServerFormController } from "./server/server-management-controller"
|
||||
import { ServerConnectionForm, ServerConnectionList } from "./dialog-select-server"
|
||||
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
|
||||
|
||||
export const SettingsServers: Component = () => {
|
||||
const language = useLanguage()
|
||||
const domain = useServerDomainController()
|
||||
const form = useServerFormController()
|
||||
const controller = useServerManagementController()
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
|
||||
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
|
||||
<Show
|
||||
when={form.state.open()}
|
||||
when={controller.isFormMode()}
|
||||
fallback={
|
||||
<>
|
||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
||||
@@ -21,25 +18,13 @@ export const SettingsServers: Component = () => {
|
||||
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />
|
||||
<ServerConnectionList controller={controller} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<div class="flex items-center gap-2 -ml-2">
|
||||
<IconButton
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={form.reset}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
<span>
|
||||
{form.state.adding() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnectionForm form={form} />
|
||||
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
|
||||
<ServerConnectionForm controller={controller} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type ServerConnection } from "@/context/server"
|
||||
import { useServerFormController } from "../server/server-management-controller"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import "./settings-v2.css"
|
||||
|
||||
export const DialogServerV2: Component<{
|
||||
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const form = useServerFormController({
|
||||
const controller = useServerManagementController({
|
||||
onSelect: () => dialog.close(),
|
||||
navigateOnAdd: false,
|
||||
})
|
||||
const [opened, setOpened] = createSignal(false)
|
||||
|
||||
onMount(() => {
|
||||
if (props.mode === "add") form.start.add()
|
||||
if (props.mode === "edit" && props.server) form.start.edit(props.server)
|
||||
if (props.mode === "add") controller.startAdd()
|
||||
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
|
||||
setOpened(true)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
form.reset()
|
||||
controller.resetForm()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) return
|
||||
if (form.state.open()) return
|
||||
if (controller.isFormMode()) return
|
||||
dialog.close()
|
||||
})
|
||||
|
||||
const keyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Enter" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
form.submit()
|
||||
controller.submitForm()
|
||||
}
|
||||
|
||||
const title = () =>
|
||||
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
|
||||
|
||||
const submitLabel = () => {
|
||||
if (form.state.busy()) return language.t("dialog.server.add.checking")
|
||||
if (controller.formBusy()) return language.t("dialog.server.add.checking")
|
||||
if (props.mode === "add") return language.t("dialog.server.add.button")
|
||||
return language.t("common.save")
|
||||
}
|
||||
@@ -66,16 +66,16 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
value={controller.formValue()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
invalid={!!controller.formError()}
|
||||
disabled={controller.formBusy()}
|
||||
autofocus
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span class="settings-v2-server-dialog-error">{form.state.error()}</span>
|
||||
<Show when={controller.formError()}>
|
||||
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -84,10 +84,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
value={controller.formName()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.username()}
|
||||
value={controller.formUsername()}
|
||||
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.username(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
value={controller.formPassword()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
disabled={controller.formBusy()}
|
||||
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</ButtonV2>
|
||||
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
|
||||
{submitLabel()}
|
||||
</ButtonV2>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
export type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function createShellOptions(input: {
|
||||
shells: ShellOption[]
|
||||
current: string | undefined
|
||||
automatic: string
|
||||
terminalOnly: string
|
||||
}) {
|
||||
const counts = input.shells.reduce((result, shell) => {
|
||||
result.set(shell.name, (result.get(shell.name) ?? 0) + 1)
|
||||
return result
|
||||
}, new Map<string, number>())
|
||||
const options: ShellSelectOption[] = [
|
||||
{ id: "auto", value: "", label: input.automatic },
|
||||
...input.shells.map((shell) => {
|
||||
const ambiguous = (counts.get(shell.name) ?? 0) > 1
|
||||
const name = ambiguous ? shell.path : shell.name
|
||||
return {
|
||||
id: shell.path,
|
||||
value: ambiguous ? shell.path : shell.name,
|
||||
label: shell.acceptable ? name : `${name} (${input.terminalOnly})`,
|
||||
}
|
||||
}),
|
||||
]
|
||||
if (input.current && !options.some((option) => option.value === input.current)) {
|
||||
options.push({ id: input.current, value: input.current, label: input.current })
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) {
|
||||
let cleanup: (() => void) | undefined
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
let run = 0
|
||||
|
||||
const stop = () => {
|
||||
run += 1
|
||||
cleanup?.()
|
||||
clearTimeout(timeout)
|
||||
cleanup = undefined
|
||||
timeout = undefined
|
||||
}
|
||||
const play = (id: string | undefined) => {
|
||||
stop()
|
||||
if (!id) return
|
||||
const current = ++run
|
||||
timeout = setTimeout(() => {
|
||||
timeout = undefined
|
||||
void player(id).then((next) => {
|
||||
if (run === current) {
|
||||
cleanup = next
|
||||
return
|
||||
}
|
||||
next?.()
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
onCleanup(stop)
|
||||
return { play, stop }
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, test, vi } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||
|
||||
describe("settings v2 controllers", () => {
|
||||
test("normalizes shell names and preserves an unavailable configured shell", () => {
|
||||
expect(
|
||||
createShellOptions({
|
||||
shells: [
|
||||
{ path: "/bin/bash", name: "bash", acceptable: true },
|
||||
{ path: "/opt/bash", name: "bash", acceptable: false },
|
||||
{ path: "/bin/zsh", name: "zsh", acceptable: true },
|
||||
],
|
||||
current: "fish",
|
||||
automatic: "Automatic",
|
||||
terminalOnly: "Terminal only",
|
||||
}),
|
||||
).toEqual([
|
||||
{ id: "auto", value: "", label: "Automatic" },
|
||||
{ id: "/bin/bash", value: "/bin/bash", label: "/bin/bash" },
|
||||
{ id: "/opt/bash", value: "/opt/bash", label: "/opt/bash (Terminal only)" },
|
||||
{ id: "/bin/zsh", value: "zsh", label: "zsh" },
|
||||
{ id: "fish", value: "fish", label: "fish" },
|
||||
])
|
||||
})
|
||||
|
||||
test("debounces previews and stops owned audio on disposal", async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const played: string[] = []
|
||||
const stopped: string[] = []
|
||||
const owned = createRoot((dispose) => ({
|
||||
dispose,
|
||||
preview: createSoundPreviewController(async (id) => {
|
||||
played.push(id ?? "")
|
||||
return () => stopped.push(id ?? "")
|
||||
}),
|
||||
}))
|
||||
|
||||
owned.preview.play("first")
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(played).toEqual([])
|
||||
|
||||
owned.preview.play("second")
|
||||
vi.advanceTimersByTime(100)
|
||||
await Promise.resolve()
|
||||
expect(played).toEqual(["second"])
|
||||
|
||||
owned.dispose()
|
||||
expect(stopped).toEqual(["second"])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,198 +0,0 @@
|
||||
import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import {
|
||||
createShellOptions,
|
||||
createSoundPreviewController,
|
||||
type ShellOption,
|
||||
type ShellSelectOption,
|
||||
} from "./general-controller-behavior"
|
||||
|
||||
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
|
||||
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
|
||||
|
||||
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
|
||||
const permission = usePermission()
|
||||
const serverSync = useServerSync()
|
||||
const directory = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return undefined
|
||||
return serverSync().session.lineage.peek(id)?.session.directory
|
||||
})
|
||||
|
||||
return {
|
||||
accepting: createMemo(() => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return false
|
||||
return permission.isAutoAccepting(id, dir)
|
||||
}),
|
||||
enabled: createMemo(() => !!directory()),
|
||||
set: (checked: boolean) => {
|
||||
const id = sessionID()
|
||||
const dir = directory()
|
||||
if (!id || !dir) return
|
||||
if (checked) return permission.enableAutoAccept(id, dir)
|
||||
permission.disableAutoAccept(id, dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createShellSettingsController() {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? []
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
const current = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
const options = createMemo(() =>
|
||||
createShellOptions({
|
||||
shells: shells.latest,
|
||||
current: current(),
|
||||
automatic: language.t("settings.general.row.shell.autoDefault"),
|
||||
terminalOnly: language.t("settings.general.row.shell.terminalOnly"),
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
current: createMemo(() => options().find((option) => option.value === current()) ?? options()[0]),
|
||||
options,
|
||||
select: (option: ShellSelectOption | null) => {
|
||||
if (!option || option.value === current()) return
|
||||
void serverSync().updateConfig({ shell: option.value })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createAppearanceSettingsController() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
const schemes = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
onMount(() => void theme.loadThemes())
|
||||
|
||||
return {
|
||||
scheme: {
|
||||
options: schemes,
|
||||
current: createMemo(() => schemes().find((option) => option.value === theme.colorScheme())),
|
||||
select: (option: { value: ColorScheme } | null) => option && theme.setColorScheme(option.value),
|
||||
},
|
||||
theme: {
|
||||
options: themes,
|
||||
current: createMemo(() => themes().find((option) => option.id === theme.themeId())),
|
||||
select: (option: { id: string } | null) => option && theme.setTheme(option.id),
|
||||
},
|
||||
fonts: {
|
||||
ui: createMemo(() => ({
|
||||
value: sansInput(settings.appearance.uiFont()),
|
||||
family: sansFontFamily(settings.appearance.uiFont()),
|
||||
placeholder: sansDefault,
|
||||
})),
|
||||
code: createMemo(() => ({
|
||||
value: monoInput(settings.appearance.font()),
|
||||
family: monoFontFamily(settings.appearance.font()),
|
||||
placeholder: monoDefault,
|
||||
})),
|
||||
terminal: createMemo(() => ({
|
||||
value: terminalInput(settings.appearance.terminalFont()),
|
||||
family: terminalFontFamily(settings.appearance.terminalFont()),
|
||||
placeholder: terminalDefault,
|
||||
})),
|
||||
setUI: (value: string) => settings.appearance.setUIFont(value),
|
||||
setCode: (value: string) => settings.appearance.setFont(value),
|
||||
setTerminal: (value: string) => settings.appearance.setTerminalFont(value),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
export const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
export type SoundSelectOption = (typeof soundOptions)[number]
|
||||
|
||||
export function createSoundSettingsController() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const preview = createSoundPreviewController(playSoundById)
|
||||
const channel = (
|
||||
enabled: Accessor<boolean>,
|
||||
current: Accessor<string>,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
current: createMemo(() =>
|
||||
enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound,
|
||||
),
|
||||
highlight: (option: SoundSelectOption | undefined) => {
|
||||
if (!option) return
|
||||
preview.play(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
select: (option: SoundSelectOption | null) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
preview.stop()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
preview.play(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
options: soundOptions,
|
||||
label: (option: SoundSelectOption) => language.t(option.label),
|
||||
agent: channel(
|
||||
settings.sounds.agentEnabled,
|
||||
settings.sounds.agent,
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
),
|
||||
permissions: channel(
|
||||
settings.sounds.permissionsEnabled,
|
||||
settings.sounds.permissions,
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
),
|
||||
errors: channel(
|
||||
settings.sounds.errorsEnabled,
|
||||
settings.sounds.errors,
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
|
||||
export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
|
||||
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
|
||||
export type SoundSettingsController = ReturnType<typeof createSoundSettingsController>
|
||||
@@ -1,346 +1,182 @@
|
||||
import { Component, Show, createMemo, createResource, type Accessor } from "solid-js"
|
||||
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useUpdaterAction } from "../updater-action"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
terminalDefault,
|
||||
terminalFontFamily,
|
||||
terminalInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import {
|
||||
createAppearanceSettingsController,
|
||||
createPermissionScopeController,
|
||||
createShellSettingsController,
|
||||
createSoundSettingsController,
|
||||
type AppearanceSettingsController,
|
||||
type PermissionScopeController,
|
||||
type ShellSelectOption,
|
||||
type ShellSettingsController,
|
||||
type SoundSelectOption,
|
||||
type SoundSettingsController,
|
||||
} from "./general-controllers"
|
||||
import "./settings-v2.css"
|
||||
|
||||
const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<PermissionScopeSettingView
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
checked={props.controller.accepting}
|
||||
enabled={props.controller.enabled}
|
||||
onChange={(checked) => props.controller.set(checked)}
|
||||
/>
|
||||
)
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
timeout: undefined as NodeJS.Timeout | undefined,
|
||||
run: 0,
|
||||
}
|
||||
|
||||
const PermissionScopeSettingView: Component<{
|
||||
title: string
|
||||
description: string
|
||||
checked: Accessor<boolean>
|
||||
enabled: Accessor<boolean>
|
||||
onChange: (checked: boolean) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={props.checked()} disabled={!props.enabled()} onChange={props.onChange} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<ShellSettingView
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
options={props.controller.options}
|
||||
current={props.controller.current}
|
||||
onSelect={(option) => props.controller.select(option)}
|
||||
/>
|
||||
)
|
||||
type ThemeOption = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const ShellSettingView: Component<{
|
||||
title: string
|
||||
description: string
|
||||
options: Accessor<ShellSelectOption[]>
|
||||
current: Accessor<ShellSelectOption | undefined>
|
||||
onSelect: (option: ShellSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-shell"
|
||||
options={props.options()}
|
||||
current={props.current()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.label}
|
||||
onSelect={props.onSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<AppearanceSectionView
|
||||
t={language.t}
|
||||
schemeOptions={props.controller.scheme.options}
|
||||
currentScheme={props.controller.scheme.current}
|
||||
onSchemeSelect={props.controller.scheme.select}
|
||||
themeOptions={props.controller.theme.options}
|
||||
currentTheme={props.controller.theme.current}
|
||||
onThemeSelect={props.controller.theme.select}
|
||||
uiFont={props.controller.fonts.ui}
|
||||
codeFont={props.controller.fonts.code}
|
||||
terminalFont={props.controller.fonts.terminal}
|
||||
onUIFontInput={props.controller.fonts.setUI}
|
||||
onCodeFontInput={props.controller.fonts.setCode}
|
||||
onTerminalFontInput={props.controller.fonts.setTerminal}
|
||||
/>
|
||||
)
|
||||
type ShellOption = {
|
||||
path: string
|
||||
name: string
|
||||
acceptable: boolean
|
||||
}
|
||||
|
||||
type AppearanceSectionViewProps = {
|
||||
t: ReturnType<typeof useLanguage>["t"]
|
||||
schemeOptions: AppearanceSettingsController["scheme"]["options"]
|
||||
currentScheme: AppearanceSettingsController["scheme"]["current"]
|
||||
onSchemeSelect: AppearanceSettingsController["scheme"]["select"]
|
||||
themeOptions: AppearanceSettingsController["theme"]["options"]
|
||||
currentTheme: AppearanceSettingsController["theme"]["current"]
|
||||
onThemeSelect: AppearanceSettingsController["theme"]["select"]
|
||||
uiFont: AppearanceSettingsController["fonts"]["ui"]
|
||||
codeFont: AppearanceSettingsController["fonts"]["code"]
|
||||
terminalFont: AppearanceSettingsController["fonts"]["terminal"]
|
||||
onUIFontInput: (value: string) => void
|
||||
onCodeFontInput: (value: string) => void
|
||||
onTerminalFontInput: (value: string) => void
|
||||
type ShellSelectOption = {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const AppearanceSectionView: Component<AppearanceSectionViewProps> = (props) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{props.t("settings.general.section.appearance")}</h3>
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={props.t("settings.general.row.colorScheme.title")}
|
||||
description={props.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={props.schemeOptions()}
|
||||
current={props.currentScheme()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.value}
|
||||
label={(option) => option.label}
|
||||
onSelect={props.onSchemeSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={props.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{props.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{props.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={props.themeOptions()}
|
||||
current={props.currentTheme()}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(option) => option.id}
|
||||
label={(option) => option.name}
|
||||
onSelect={props.onThemeSelect}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<FontSettingView
|
||||
action="settings-ui-font"
|
||||
title={props.t("settings.general.row.uiFont.title")}
|
||||
description={props.t("settings.general.row.uiFont.description")}
|
||||
font={props.uiFont}
|
||||
onInput={props.onUIFontInput}
|
||||
/>
|
||||
<FontSettingView
|
||||
action="settings-code-font"
|
||||
title={props.t("settings.general.row.font.title")}
|
||||
description={props.t("settings.general.row.font.description")}
|
||||
font={props.codeFont}
|
||||
onInput={props.onCodeFontInput}
|
||||
/>
|
||||
<FontSettingView
|
||||
action="settings-terminal-font"
|
||||
title={props.t("settings.general.row.terminalFont.title")}
|
||||
description={props.t("settings.general.row.terminalFont.description")}
|
||||
font={props.terminalFont}
|
||||
onInput={props.onTerminalFontInput}
|
||||
/>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const FontSettingView: Component<{
|
||||
action: string
|
||||
title: string
|
||||
description: string
|
||||
font: Accessor<{ value: string; family: string; placeholder: string }>
|
||||
onInput: (value: string) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action={props.action}
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={props.font().value}
|
||||
onInput={(event) => props.onInput(event.currentTarget.value)}
|
||||
placeholder={props.font().placeholder}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={props.title}
|
||||
style={{ "font-family": props.font().family }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
|
||||
const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<SoundsSectionView
|
||||
t={language.t}
|
||||
options={props.controller.options}
|
||||
label={props.controller.label}
|
||||
agentCurrent={props.controller.agent.current}
|
||||
onAgentHighlight={(option) => props.controller.agent.highlight(option)}
|
||||
onAgentSelect={(option) => props.controller.agent.select(option)}
|
||||
permissionsCurrent={props.controller.permissions.current}
|
||||
onPermissionsHighlight={(option) => props.controller.permissions.highlight(option)}
|
||||
onPermissionsSelect={(option) => props.controller.permissions.select(option)}
|
||||
errorsCurrent={props.controller.errors.current}
|
||||
onErrorsHighlight={(option) => props.controller.errors.highlight(option)}
|
||||
onErrorsSelect={(option) => props.controller.errors.select(option)}
|
||||
/>
|
||||
)
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
demoSoundState.run += 1
|
||||
if (demoSoundState.cleanup) {
|
||||
demoSoundState.cleanup()
|
||||
}
|
||||
clearTimeout(demoSoundState.timeout)
|
||||
demoSoundState.cleanup = undefined
|
||||
}
|
||||
|
||||
const SoundsSectionView: Component<{
|
||||
t: ReturnType<typeof useLanguage>["t"]
|
||||
options: SoundSelectOption[]
|
||||
label: (option: SoundSelectOption) => string
|
||||
agentCurrent: Accessor<SoundSelectOption>
|
||||
onAgentHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onAgentSelect: (option: SoundSelectOption | null) => void
|
||||
permissionsCurrent: Accessor<SoundSelectOption>
|
||||
onPermissionsHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onPermissionsSelect: (option: SoundSelectOption | null) => void
|
||||
errorsCurrent: Accessor<SoundSelectOption>
|
||||
onErrorsHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onErrorsSelect: (option: SoundSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{props.t("settings.general.section.sounds")}</h3>
|
||||
<SettingsListV2>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-agent"
|
||||
title={props.t("settings.general.sounds.agent.title")}
|
||||
description={props.t("settings.general.sounds.agent.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.agentCurrent}
|
||||
onHighlight={props.onAgentHighlight}
|
||||
onSelect={props.onAgentSelect}
|
||||
/>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-permissions"
|
||||
title={props.t("settings.general.sounds.permissions.title")}
|
||||
description={props.t("settings.general.sounds.permissions.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.permissionsCurrent}
|
||||
onHighlight={props.onPermissionsHighlight}
|
||||
onSelect={props.onPermissionsSelect}
|
||||
/>
|
||||
<SoundSettingView
|
||||
action="settings-sounds-errors"
|
||||
title={props.t("settings.general.sounds.errors.title")}
|
||||
description={props.t("settings.general.sounds.errors.description")}
|
||||
options={props.options}
|
||||
label={props.label}
|
||||
current={props.errorsCurrent}
|
||||
onHighlight={props.onErrorsHighlight}
|
||||
onSelect={props.onErrorsSelect}
|
||||
/>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
const playDemoSound = (id: string | undefined) => {
|
||||
stopDemoSound()
|
||||
if (!id) return
|
||||
|
||||
const SoundSettingView: Component<{
|
||||
action: string
|
||||
title: string
|
||||
description: string
|
||||
options: SoundSelectOption[]
|
||||
label: (option: SoundSelectOption) => string
|
||||
current: Accessor<SoundSelectOption>
|
||||
onHighlight: (option: SoundSelectOption | undefined) => void
|
||||
onSelect: (option: SoundSelectOption | null) => void
|
||||
}> = (props) => (
|
||||
<SettingsRowV2 title={props.title} description={props.description}>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action={props.action}
|
||||
options={props.options}
|
||||
current={props.current()}
|
||||
value={(option) => option.id}
|
||||
label={props.label}
|
||||
onHighlight={props.onHighlight}
|
||||
onSelect={props.onSelect}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
)
|
||||
const run = ++demoSoundState.run
|
||||
demoSoundState.timeout = setTimeout(() => {
|
||||
void playSoundById(id).then((cleanup) => {
|
||||
if (demoSoundState.run !== run) {
|
||||
cleanup?.()
|
||||
return
|
||||
}
|
||||
demoSoundState.cleanup = cleanup
|
||||
})
|
||||
}, 100)
|
||||
}
|
||||
|
||||
export const SettingsGeneralV2: Component<{
|
||||
sessionID?: string
|
||||
}> = (props) => {
|
||||
const theme = useTheme()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
const serverSdk = useServerSDK()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
|
||||
const updater = useUpdaterAction()
|
||||
const permissionScope = createPermissionScopeController(() => props.sessionID)
|
||||
const shell = createShellSettingsController()
|
||||
const appearance = createAppearanceSettingsController()
|
||||
const sounds = createSoundSettingsController()
|
||||
|
||||
const dir = createMemo(() => {
|
||||
if (!props.sessionID) return undefined
|
||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
||||
})
|
||||
const accepting = createMemo(() => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return false
|
||||
return permission.isAutoAccepting(props.sessionID, value)
|
||||
})
|
||||
|
||||
const toggleAccept = (checked: boolean) => {
|
||||
const value = dir()
|
||||
if (!value || !props.sessionID) return
|
||||
|
||||
if (checked) {
|
||||
permission.enableAutoAccept(props.sessionID, value)
|
||||
return
|
||||
}
|
||||
|
||||
permission.disableAutoAccept(props.sessionID, value)
|
||||
}
|
||||
const desktop = createMemo(() => platform.platform === "desktop")
|
||||
|
||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||
|
||||
const [shells] = createResource(
|
||||
async () => {
|
||||
const sdk = serverSdk()
|
||||
if ((await sdk.protocol) === "v1") {
|
||||
return (await sdk.client.pty.shells()).data ?? []
|
||||
}
|
||||
// return (await sdk.api.pty.shells()).data
|
||||
return [] as ShellOption[]
|
||||
},
|
||||
{ initialValue: [] as ShellOption[] },
|
||||
)
|
||||
|
||||
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
|
||||
() => desktop() && "getPinchZoomEnabled" in platform,
|
||||
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
|
||||
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
|
||||
{ initialValue: false },
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void theme.loadThemes()
|
||||
})
|
||||
|
||||
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
|
||||
const currentShell = createMemo(() => serverSync().data.config.shell ?? "")
|
||||
|
||||
const shellOptions = createMemo<ShellSelectOption[]>(() => {
|
||||
const list = shells.latest
|
||||
const current = serverSync().data.config.shell
|
||||
|
||||
const nameCounts = new Map<string, number>()
|
||||
for (const s of list) {
|
||||
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
|
||||
}
|
||||
|
||||
const options = [
|
||||
autoOption,
|
||||
...list.map((s) => {
|
||||
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
|
||||
const text = ambiguousName ? s.path : s.name
|
||||
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
|
||||
return {
|
||||
id: s.path,
|
||||
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
|
||||
value: ambiguousName ? s.path : s.name,
|
||||
label,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if (current && !options.some((o) => o.value === current)) {
|
||||
options.push({ id: current, value: current, label: current })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const onPinchZoomChange = (checked: boolean) => {
|
||||
setPinchZoom(checked)
|
||||
const update = platform.setPinchZoomEnabled?.(checked)
|
||||
@@ -348,6 +184,12 @@ export const SettingsGeneralV2: Component<{
|
||||
void update.catch(() => setPinchZoom(!checked))
|
||||
}
|
||||
|
||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||
{ value: "system", label: language.t("theme.scheme.system") },
|
||||
{ value: "light", label: language.t("theme.scheme.light") },
|
||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||
])
|
||||
|
||||
const languageOptions = createMemo(() =>
|
||||
language.locales.map((locale) => ({
|
||||
value: locale,
|
||||
@@ -355,6 +197,39 @@ export const SettingsGeneralV2: Component<{
|
||||
})),
|
||||
)
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
const terminal = () => terminalInput(settings.appearance.terminalFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
current: () => string,
|
||||
setEnabled: (value: boolean) => void,
|
||||
set: (id: string) => void,
|
||||
) => ({
|
||||
options: soundOptions,
|
||||
current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound,
|
||||
value: (o: (typeof soundOptions)[number]) => o.id,
|
||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||
if (!option) return
|
||||
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||
},
|
||||
onSelect: (option: (typeof soundOptions)[number] | null) => {
|
||||
if (!option) return
|
||||
if (option.id === "none") {
|
||||
setEnabled(false)
|
||||
stopDemoSound()
|
||||
return
|
||||
}
|
||||
setEnabled(true)
|
||||
set(option.id)
|
||||
playDemoSound(option.id)
|
||||
},
|
||||
})
|
||||
|
||||
const InterfaceSection = () => (
|
||||
<LayoutTransitionToggle
|
||||
title={language.t("settings.general.row.newInterface.title")}
|
||||
@@ -376,7 +251,7 @@ export const SettingsGeneralV2: Component<{
|
||||
title={language.t("settings.general.row.newInterfaceNotice.title")}
|
||||
description={language.t("settings.general.row.newInterfaceNotice.description")}
|
||||
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
|
||||
onDismiss={() => settings.general.dismissNewInterfaceNotice()}
|
||||
onDismiss={settings.general.dismissNewInterfaceNotice}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -400,9 +275,35 @@ export const SettingsGeneralV2: Component<{
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<PermissionScopeSetting controller={permissionScope} />
|
||||
<SettingsRowV2
|
||||
title={language.t("command.permissions.autoaccept.enable")}
|
||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||
>
|
||||
<div data-action="settings-auto-accept-permissions">
|
||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<ShellSetting controller={shell} />
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.shell.title")}
|
||||
description={language.t("settings.general.row.shell.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-shell"
|
||||
options={shellOptions()}
|
||||
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
if (option.value === currentShell()) return
|
||||
serverSync().updateConfig({ shell: option.value })
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||
@@ -513,6 +414,124 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
)
|
||||
|
||||
const AppearanceSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.appearance")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.colorScheme.title")}
|
||||
description={language.t("settings.general.row.colorScheme.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-color-scheme"
|
||||
options={colorSchemeOptions()}
|
||||
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.theme.title")}
|
||||
description={
|
||||
<>
|
||||
{language.t("settings.general.row.theme.description")}{" "}
|
||||
<Link class="settings-v2-link" href="https://opencode.ai/docs/themes/">
|
||||
{language.t("common.learnMore")}
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-theme"
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-ui-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={sans()}
|
||||
onInput={(event) => settings.appearance.setUIFont(event.currentTarget.value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.uiFont.title")}
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-code-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={mono()}
|
||||
onInput={(event) => settings.appearance.setFont(event.currentTarget.value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.font.title")}
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.terminalFont.title")}
|
||||
description={language.t("settings.general.row.terminalFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextInputV2
|
||||
data-action="settings-terminal-font"
|
||||
type="text"
|
||||
appearance="base"
|
||||
value={terminal()}
|
||||
onInput={(event) => settings.appearance.setTerminalFont(event.currentTarget.value)}
|
||||
placeholder={terminalDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label={language.t("settings.general.row.terminalFont.title")}
|
||||
style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NotificationsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.notifications")}</h3>
|
||||
@@ -557,6 +576,68 @@ export const SettingsGeneralV2: Component<{
|
||||
</div>
|
||||
)
|
||||
|
||||
const SoundsSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.sounds")}</h3>
|
||||
|
||||
<SettingsListV2>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.agent.title")}
|
||||
description={language.t("settings.general.sounds.agent.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-agent"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.agentEnabled(),
|
||||
() => settings.sounds.agent(),
|
||||
(value) => settings.sounds.setAgentEnabled(value),
|
||||
(id) => settings.sounds.setAgent(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.permissions.title")}
|
||||
description={language.t("settings.general.sounds.permissions.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-permissions"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.permissionsEnabled(),
|
||||
() => settings.sounds.permissions(),
|
||||
(value) => settings.sounds.setPermissionsEnabled(value),
|
||||
(id) => settings.sounds.setPermissions(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.sounds.errors.title")}
|
||||
description={language.t("settings.general.sounds.errors.description")}
|
||||
>
|
||||
<SelectV2
|
||||
appearance="inline"
|
||||
data-action="settings-sounds-errors"
|
||||
{...soundSelectProps(
|
||||
() => settings.sounds.errorsEnabled(),
|
||||
() => settings.sounds.errors(),
|
||||
(value) => settings.sounds.setErrorsEnabled(value),
|
||||
(id) => settings.sounds.setErrors(id),
|
||||
)}
|
||||
placement="bottom-end"
|
||||
gutter={6}
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
</SettingsListV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
const UpdatesSection = () => (
|
||||
<div class="settings-v2-section">
|
||||
<h3 class="settings-v2-section-title">{language.t("settings.general.section.updates")}</h3>
|
||||
@@ -578,7 +659,7 @@ export const SettingsGeneralV2: Component<{
|
||||
title={language.t("settings.updates.row.check.title")}
|
||||
description={language.t("settings.updates.row.check.description")}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={() => updater.run()}>
|
||||
<ButtonV2 size="normal" variant="neutral" disabled={!updater.action().run} onClick={updater.run}>
|
||||
{language.t(updater.action().label)}
|
||||
</ButtonV2>
|
||||
</SettingsRowV2>
|
||||
@@ -623,11 +704,11 @@ export const SettingsGeneralV2: Component<{
|
||||
|
||||
<GeneralSection />
|
||||
|
||||
<AppearanceSection controller={appearance} />
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationsSection />
|
||||
|
||||
<SoundsSection controller={sounds} />
|
||||
<SoundsSection />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<UpdatesSection />
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection, serverName } from "@/context/server"
|
||||
import { useServerCollectionController } from "../server/server-management-controller"
|
||||
import { useServerManagementController } from "../dialog-select-server"
|
||||
import { DialogServerV2 } from "./dialog-server-v2"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
|
||||
@@ -19,16 +19,16 @@ import "./settings-v2.css"
|
||||
export const SettingsServersV2: Component = () => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const domain = useServerCollectionController()
|
||||
const controller = useServerManagementController()
|
||||
const [store, setStore] = createStore({ filter: "" })
|
||||
const wslServers = useFilteredWslServers(() => store.filter)
|
||||
|
||||
const showSearch = createMemo(
|
||||
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
|
||||
)
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const items = domain.collection.items().filter((item) => !isWslServer(item))
|
||||
const items = controller.sortedItems().filter((item) => !isWslServer(item))
|
||||
const query = store.filter.trim()
|
||||
if (!query) return items
|
||||
return fuzzysort
|
||||
@@ -39,11 +39,11 @@ export const SettingsServersV2: Component = () => {
|
||||
})
|
||||
|
||||
const openAdd = () => {
|
||||
void dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
dialog.push(() => <DialogServerV2 mode="add" />)
|
||||
}
|
||||
|
||||
const openEdit = (server: ServerConnection.Http) => {
|
||||
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
|
||||
}
|
||||
>
|
||||
<SettingsListV2>
|
||||
<WslServerSettings domain={domain} servers={wslServers} />
|
||||
<WslServerSettings controller={controller} servers={wslServers} />
|
||||
<For each={filtered()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const health = () => domain.collection.health()[key]
|
||||
const isDefault = () => domain.defaults.key() === key
|
||||
const health = () => controller.status()[key]
|
||||
const isDefault = () => controller.defaultKey() === key
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
@@ -122,10 +122,10 @@ export const SettingsServersV2: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={domain.defaults.available() && isDefault()}>
|
||||
<Show when={controller.canDefault() && isDefault()}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
|
||||
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
import { useServerActionsController } from "@/components/server/server-management-controller"
|
||||
import { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
|
||||
import { type LocalProject } from "@/context/layout"
|
||||
@@ -22,7 +22,7 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const language = useLanguage()
|
||||
const notification = useNotification()
|
||||
const openSettings = useSettingsCommand()
|
||||
const serverManagement = useServerActionsController()
|
||||
const serverManagement = useServerManagementController({ navigateOnAdd: false })
|
||||
const [_state, setState, _, ready] = persisted(
|
||||
Persist.global("home.servers", ["home.servers.v1"]),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
@@ -56,11 +56,11 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
const key = ServerConnection.key(conn)
|
||||
setState("collapsed", key, !state().collapsed[key])
|
||||
},
|
||||
canDefault: serverManagement.defaults.available,
|
||||
defaultKey: serverManagement.defaults.key,
|
||||
canDefault: serverManagement.canDefault,
|
||||
defaultKey: serverManagement.defaultKey,
|
||||
setDefault: (conn: ServerConnection.Any | undefined) =>
|
||||
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
|
||||
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
|
||||
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
|
||||
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
|
||||
focus: home.selection.focusServer,
|
||||
},
|
||||
|
||||
@@ -1,27 +1,290 @@
|
||||
import { createPromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { createNewSessionCommandController } from "./new-session/new-session-command-controller"
|
||||
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
|
||||
import { NewSession } from "./new-session/new-session"
|
||||
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
PromptProjectSelector,
|
||||
createPromptProjectController,
|
||||
} from "@/components/prompt-project-selector"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import createPresence from "solid-presence"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
|
||||
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
const providerTipExitDuration = 250
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
* composer for a brand-new session — no terminal, review pane, file tree, or message
|
||||
* timeline. Submitting promotes the draft into a real session (see prompt-input/submit).
|
||||
*/
|
||||
export default function NewSessionPage() {
|
||||
const workspace = createNewSessionWorkspaceController()
|
||||
const draft = createNewSessionDraftController({
|
||||
worktree: workspace.selection.value,
|
||||
resetWorktree: workspace.selection.reset,
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const openProviders = () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
}
|
||||
useSettingsCommand()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const local = useLocal()
|
||||
const model = createPromptModelSelection({ agent: local.agent.current })
|
||||
|
||||
useComposerCommands({ model })
|
||||
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
model,
|
||||
})
|
||||
const project = createPromptProjectController({
|
||||
controls: draft.project.controls,
|
||||
onDone: draft.input.restoreFocus,
|
||||
const projectControls = createPromptProjectControls()
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (!showWorkspaceBar()) return "main"
|
||||
if (store.worktree) return store.worktree
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
const commands = createNewSessionCommandController({
|
||||
restoreFocus: draft.input.restoreFocus,
|
||||
project: {
|
||||
empty: project.empty,
|
||||
open: () => project.setOpen(true),
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const selectedBranch = createMemo(() => {
|
||||
const worktree = newSessionWorktree()
|
||||
if (worktree === "main" || worktree === "create") return localBranch()
|
||||
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
|
||||
})
|
||||
const promptInputV2Controller = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return inputController()
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return newSessionWorktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
|
||||
onSubmit: () => comments.clear(),
|
||||
})
|
||||
const projectController = createPromptProjectController({
|
||||
controls: projectControls,
|
||||
onDone: promptInputV2Controller.restoreFocus,
|
||||
})
|
||||
|
||||
return <NewSession draft={draft} commands={commands} workspace={workspace} project={project} />
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: () => promptInputV2Controller.restoreFocus(),
|
||||
},
|
||||
{
|
||||
id: "project.select",
|
||||
title: language.t("session.new.project.search"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+shift+o",
|
||||
disabled: projectController.empty(),
|
||||
onSelect: () => projectController.setOpen(true),
|
||||
},
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
promptInputV2Controller.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => prompt.ready.promise ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={settings.visibility.status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<div class="flex flex-col gap-8">
|
||||
<PromptInputV2Composer controller={promptInputV2Controller} />
|
||||
<Show when={projectController.empty()}>
|
||||
<PromptProjectAddButton controller={projectController} />
|
||||
</Show>
|
||||
<Show when={projectController.selected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||
<PromptProjectSelector controller={projectController} placement="bottom" />
|
||||
<Show
|
||||
when={showWorkspaceBar()}
|
||||
fallback={<PromptGitStatus branch={selectedBranch()} noGit={sync().project?.vcs !== "git"} />}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
workspaces={sync().project?.sandboxes ?? []}
|
||||
branch={selectedBranch()}
|
||||
onChange={(value) =>
|
||||
setStore(
|
||||
"worktree",
|
||||
value === "main" && sync().project?.worktree !== sdk().directory
|
||||
? sync().project?.worktree
|
||||
: value,
|
||||
)
|
||||
}
|
||||
onDone={promptInputV2Controller.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
{/*</Show>*/}
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={() => serverSync().child(sdk().directory)[0].provider_ready}
|
||||
connected={() => providers.paid().length > 0}
|
||||
openProviders={openProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
createStore({ dismissedAt: 0 }),
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
props.ready() &&
|
||||
persistedReady() &&
|
||||
!props.connected() &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
|
||||
function dismiss() {
|
||||
setPersistedState("dismissedAt", Date.now())
|
||||
}
|
||||
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: () => visible(),
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{
|
||||
"data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={props.openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function createNewSessionCommandController(input: {
|
||||
restoreFocus: () => void
|
||||
project: {
|
||||
empty: () => boolean
|
||||
open: () => void
|
||||
}
|
||||
}) {
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
|
||||
useSettingsCommand()
|
||||
command.register("new-session", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
title: language.t("command.palette"),
|
||||
hidden: true,
|
||||
onSelect: async () => {
|
||||
const { DialogSelectFile } = await import("@/components/dialog-select-file")
|
||||
void dialog.show(() => <DialogSelectFile />)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "input.focus",
|
||||
title: language.t("command.input.focus"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "ctrl+l",
|
||||
onSelect: input.restoreFocus,
|
||||
},
|
||||
{
|
||||
id: "project.select",
|
||||
title: language.t("session.new.project.search"),
|
||||
category: language.t("command.category.project"),
|
||||
keybind: "mod+shift+o",
|
||||
disabled: input.project.empty(),
|
||||
onSelect: input.project.open,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
provider: {
|
||||
ready: () => serverSync().child(sdk().directory)[0].provider_ready,
|
||||
connected: () => providers.paid().length > 0,
|
||||
open: () => {
|
||||
void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={() => sdk().directory} />)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionCommandController = ReturnType<typeof createNewSessionCommandController>
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { createEffect, untrack } from "solid-js"
|
||||
import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
|
||||
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const local = useLocal()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const model = createPromptModelSelection({ agent: () => local.agent.current() })
|
||||
|
||||
useComposerCommands({ model })
|
||||
|
||||
const controls = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync().queryOptions,
|
||||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls()
|
||||
const input = usePromptInputV2Controller({
|
||||
get controls() {
|
||||
return controls()
|
||||
},
|
||||
get newSessionWorktree() {
|
||||
return workspace.worktree()
|
||||
},
|
||||
onNewSessionWorktreeReset: workspace.resetWorktree,
|
||||
onSubmit: comments.clear,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
untrack(() => {
|
||||
const text = searchParams.prompt
|
||||
if (!text) return
|
||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||
setSearchParams({ ...searchParams, prompt: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
input,
|
||||
prompt: {
|
||||
ready: prompt.ready,
|
||||
readyPromise: () => prompt.ready.promise,
|
||||
},
|
||||
project: {
|
||||
controls: projectControls,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionDraftController = ReturnType<typeof createNewSessionDraftController>
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { Show, createEffect, createMemo, createResource, createSignal, type Accessor, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import createPresence from "solid-presence"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import type { PromptProject } from "@/components/prompt-project-selector"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
export function NewSessionView(props: {
|
||||
rightMount: Accessor<HTMLElement | null>
|
||||
statusVisible: Accessor<boolean>
|
||||
statusLabel: Accessor<string>
|
||||
promptReady: Accessor<boolean>
|
||||
promptReadyPromise: Accessor<Promise<unknown> | undefined>
|
||||
restoreFocus: () => void
|
||||
composer: () => JSX.Element
|
||||
projectEmpty: Accessor<boolean>
|
||||
projectSelected: Accessor<PromptProject | undefined>
|
||||
projectAdd: () => JSX.Element
|
||||
projectSelector: () => JSX.Element
|
||||
workspaceVisible: Accessor<boolean>
|
||||
workspaceValue: Accessor<string>
|
||||
workspaceRoot: Accessor<string>
|
||||
workspaces: Accessor<string[]>
|
||||
branch: Accessor<string | undefined>
|
||||
noGit: Accessor<boolean>
|
||||
onWorkspaceChange: (value: string) => void
|
||||
providerReady: Accessor<boolean>
|
||||
providerConnected: Accessor<boolean>
|
||||
onOpenProviders: () => void
|
||||
}) {
|
||||
createEffect(() => {
|
||||
if (!props.promptReady()) return
|
||||
props.restoreFocus()
|
||||
})
|
||||
|
||||
const ready = Promise.resolve()
|
||||
const [suspendUntilPromptReady] = createResource(
|
||||
() => props.promptReadyPromise() ?? ready,
|
||||
(promise) => promise.then(() => true),
|
||||
)
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
{suspendUntilPromptReady()}
|
||||
<Show when={props.rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={props.statusVisible()}>
|
||||
<Tooltip placement="bottom" value={props.statusLabel()}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<div class="flex flex-col gap-8">
|
||||
{props.composer()}
|
||||
<Show when={props.projectEmpty()}>{props.projectAdd()}</Show>
|
||||
<Show when={props.projectSelected()}>
|
||||
<div class="flex min-h-7 min-w-0 flex-col items-center justify-center gap-0 text-v2-text-text-faint sm:flex-row">
|
||||
{props.projectSelector()}
|
||||
<Show
|
||||
when={props.workspaceVisible()}
|
||||
fallback={<PromptGitStatus branch={props.branch()} noGit={props.noGit()} />}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
value={props.workspaceValue()}
|
||||
projectRoot={props.workspaceRoot()}
|
||||
workspaces={props.workspaces()}
|
||||
branch={props.branch()}
|
||||
onChange={props.onWorkspaceChange}
|
||||
onDone={props.restoreFocus}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</NewSessionDesignView>
|
||||
<ProviderTip
|
||||
ready={props.providerReady}
|
||||
connected={props.providerConnected}
|
||||
openProviders={props.onOpenProviders}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderTip(props: { ready: Accessor<boolean>; connected: Accessor<boolean>; openProviders: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
createStore({ dismissedAt: 0 }),
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
props.ready() &&
|
||||
persistedReady() &&
|
||||
!props.connected() &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: visible,
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={props.openProviders}
|
||||
>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<IconV2 name="xmark-small" />
|
||||
</button>
|
||||
</TooltipV2>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
normalizeNewSessionWorktree,
|
||||
resolveNewSessionBranch,
|
||||
resolveNewSessionWorktree,
|
||||
} from "./new-session-workspace-controller"
|
||||
|
||||
describe("new session workspace selection", () => {
|
||||
test("uses main when the workspace bar is unavailable", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({
|
||||
enabled: false,
|
||||
selected: "/project/feature",
|
||||
directory: "/project/feature",
|
||||
projectWorktree: "/project",
|
||||
}),
|
||||
).toBe("main")
|
||||
})
|
||||
|
||||
test("derives an existing worktree from the current directory", () => {
|
||||
expect(
|
||||
resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }),
|
||||
).toBe("/project/feature")
|
||||
expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe(
|
||||
"main",
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes main to the project root outside the main worktree", () => {
|
||||
expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project")
|
||||
expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main")
|
||||
})
|
||||
|
||||
test("falls back to the local branch for main, create, and unknown worktrees", () => {
|
||||
const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined)
|
||||
expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe(
|
||||
"feature",
|
||||
)
|
||||
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
|
||||
})
|
||||
})
|
||||
@@ -1,78 +0,0 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
export function resolveNewSessionWorktree(input: {
|
||||
enabled: boolean
|
||||
selected?: string
|
||||
directory: string
|
||||
projectWorktree?: string
|
||||
}) {
|
||||
if (!input.enabled) return "main"
|
||||
if (input.selected) return input.selected
|
||||
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
|
||||
return "main"
|
||||
}
|
||||
|
||||
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
|
||||
if (value === "main" && projectWorktree !== directory) return projectWorktree
|
||||
return value
|
||||
}
|
||||
|
||||
export function resolveNewSessionBranch(input: {
|
||||
worktree: string
|
||||
local?: string
|
||||
worktreeBranch: (worktree: string) => string | undefined
|
||||
}) {
|
||||
if (input.worktree === "main" || input.worktree === "create") return input.local
|
||||
return input.worktreeBranch(input.worktree) ?? input.local
|
||||
}
|
||||
|
||||
export function createNewSessionWorkspaceController() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const value = createMemo(() =>
|
||||
resolveNewSessionWorktree({
|
||||
enabled: visible(),
|
||||
selected: store.worktree,
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
local: localBranch(),
|
||||
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
selection: {
|
||||
value,
|
||||
reset: () => setStore("worktree", undefined),
|
||||
set: (worktree: string) =>
|
||||
setStore("worktree", normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => sync().project?.sandboxes ?? [],
|
||||
git: () => sync().project?.vcs === "git",
|
||||
},
|
||||
bar: {
|
||||
visible,
|
||||
branch,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type NewSessionWorkspaceController = ReturnType<typeof createNewSessionWorkspaceController>
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { PromptProjectController } from "@/components/prompt-project-selector"
|
||||
import { PromptProjectAddButton, PromptProjectSelector } from "@/components/prompt-project-selector"
|
||||
import { PromptInputV2Composer } from "@/components/prompt-input-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
import type { NewSessionCommandController } from "./new-session-command-controller"
|
||||
import type { NewSessionDraftController } from "./new-session-draft-controller"
|
||||
import { NewSessionView } from "./new-session-view"
|
||||
import type { NewSessionWorkspaceController } from "./new-session-workspace-controller"
|
||||
|
||||
export function NewSession(props: {
|
||||
draft: NewSessionDraftController
|
||||
commands: NewSessionCommandController
|
||||
workspace: NewSessionWorkspaceController
|
||||
project: PromptProjectController
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
return (
|
||||
<NewSessionView
|
||||
rightMount={rightMount}
|
||||
statusVisible={settings.visibility.status}
|
||||
statusLabel={() => language.t("status.popover.trigger")}
|
||||
promptReady={props.draft.prompt.ready}
|
||||
promptReadyPromise={props.draft.prompt.readyPromise}
|
||||
restoreFocus={props.draft.input.restoreFocus}
|
||||
composer={() => <PromptInputV2Composer controller={props.draft.input} />}
|
||||
projectEmpty={props.project.empty}
|
||||
projectSelected={props.project.selected}
|
||||
projectAdd={() => <PromptProjectAddButton controller={props.project} />}
|
||||
projectSelector={() => <PromptProjectSelector controller={props.project} placement="bottom" />}
|
||||
workspaceVisible={props.workspace.bar.visible}
|
||||
workspaceValue={props.workspace.selection.value}
|
||||
workspaceRoot={props.workspace.project.root}
|
||||
workspaces={props.workspace.project.workspaces}
|
||||
branch={props.workspace.bar.branch}
|
||||
noGit={() => !props.workspace.project.git()}
|
||||
onWorkspaceChange={props.workspace.selection.set}
|
||||
providerReady={props.commands.provider.ready}
|
||||
providerConnected={props.commands.provider.connected}
|
||||
onOpenProviders={props.commands.provider.open}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { SESSION_OPEN_FILE_TAB } from "./helpers"
|
||||
import { createSessionSidePanelController, sessionSidePanelHandoffFiles } from "./session-side-panel-controller"
|
||||
|
||||
function createController(options?: { active?: string; all?: string[]; mode?: "changes" | "all" }) {
|
||||
const calls: string[] = []
|
||||
const [state, setState] = createStore({
|
||||
active: options?.active,
|
||||
all: options?.all ?? ["file://src/a.ts"],
|
||||
preview: undefined as string | undefined,
|
||||
mode: options?.mode ?? ("changes" as "changes" | "all"),
|
||||
})
|
||||
return createRoot((dispose) => ({
|
||||
dispose,
|
||||
calls,
|
||||
state,
|
||||
controller: createSessionSidePanelController({
|
||||
currentTab: () => state.active,
|
||||
allTabs: () => state.all,
|
||||
openTab: (tab) => calls.push(`open:${tab}`),
|
||||
preview: (tab) => calls.push(`preview:${tab}`),
|
||||
setActive: (tab) => calls.push(`active:${tab}`),
|
||||
normalizeFileTab: (tab) => `file://${tab.slice("file://".length).toLowerCase()}`,
|
||||
pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
|
||||
loadFile: (path) => calls.push(`load:${path}`),
|
||||
reviewEnabled: () => true,
|
||||
canReview: () => true,
|
||||
fileBrowserEnabled: () => true,
|
||||
reviewPanelOpened: () => false,
|
||||
openReviewPanel: () => calls.push("panel"),
|
||||
treeMode: () => state.mode,
|
||||
setTreeMode: (mode) => setState("mode", mode),
|
||||
fileReady: () => false,
|
||||
sessionKey: () => "session",
|
||||
selectedLines: () => null,
|
||||
persistHandoff: () => undefined,
|
||||
showDialog: () => undefined,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
describe("session side panel controller", () => {
|
||||
test("normalizes and centralizes file tab selection mutations", async () => {
|
||||
const owned = createController()
|
||||
|
||||
owned.controller.tabs.activate("file://SRC/A.ts")
|
||||
expect(owned.calls).toEqual(["load:src/a.ts", "panel", "active:file://src/a.ts"])
|
||||
|
||||
owned.calls.length = 0
|
||||
owned.controller.tabs.preview("file://SRC/B.ts")
|
||||
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel"])
|
||||
await Promise.resolve()
|
||||
expect(owned.calls).toEqual(["preview:file://src/b.ts", "load:src/b.ts", "panel", "active:file://src/b.ts"])
|
||||
|
||||
owned.calls.length = 0
|
||||
owned.controller.tabs.open("file://SRC/C.ts")
|
||||
expect(owned.calls).toEqual(["open:file://src/c.ts", "load:src/c.ts", "panel", "active:file://src/c.ts"])
|
||||
owned.dispose()
|
||||
})
|
||||
|
||||
test("derives browser selection and controls the tree mode", () => {
|
||||
const owned = createController({ active: "file://src/a.ts", all: ["file://src/a.ts"] })
|
||||
|
||||
expect(owned.controller.browser.tab()).toBe("file://src/a.ts")
|
||||
expect(owned.controller.browser.mounted()).toBe(true)
|
||||
expect(owned.controller.browser.visible()).toBe(true)
|
||||
|
||||
owned.controller.tree.setMode("invalid")
|
||||
expect(owned.state.mode).toBe("changes")
|
||||
owned.controller.tree.showAll()
|
||||
expect(owned.state.mode).toBe("all")
|
||||
owned.controller.tree.showAll()
|
||||
expect(owned.state.mode).toBe("all")
|
||||
|
||||
owned.calls.length = 0
|
||||
owned.controller.browser.open()
|
||||
expect(owned.calls[0]).toBe(`preview:${SESSION_OPEN_FILE_TAB}`)
|
||||
owned.dispose()
|
||||
})
|
||||
|
||||
test("opens the file dialog with the tree handoff callback", async () => {
|
||||
let render: (() => unknown) | undefined
|
||||
let dialogProps: { mode?: "files"; onOpenFile?: (path: string) => void } | undefined
|
||||
const owned = createController()
|
||||
const controller = createSessionSidePanelController({
|
||||
currentTab: () => undefined,
|
||||
allTabs: () => [],
|
||||
openTab: () => undefined,
|
||||
preview: () => undefined,
|
||||
setActive: () => undefined,
|
||||
normalizeFileTab: (tab) => tab,
|
||||
pathFromTab: () => undefined,
|
||||
loadFile: () => undefined,
|
||||
reviewEnabled: () => true,
|
||||
canReview: () => true,
|
||||
fileBrowserEnabled: () => true,
|
||||
reviewPanelOpened: () => true,
|
||||
openReviewPanel: () => undefined,
|
||||
treeMode: owned.controller.tree.mode,
|
||||
setTreeMode: owned.controller.tree.setMode,
|
||||
fileReady: () => false,
|
||||
sessionKey: () => "session",
|
||||
selectedLines: () => null,
|
||||
persistHandoff: () => undefined,
|
||||
showDialog: (value) => (render = value),
|
||||
loadSelectFileDialog: async () => ({
|
||||
DialogSelectFile: (props) => {
|
||||
dialogProps = props
|
||||
return null
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
await controller.dialog.openFile()
|
||||
render?.()
|
||||
expect(dialogProps?.mode).toBe("files")
|
||||
dialogProps?.onOpenFile?.("src/a.ts")
|
||||
expect(owned.state.mode).toBe("all")
|
||||
owned.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("projects only file tabs into handoff persistence", () => {
|
||||
expect(
|
||||
sessionSidePanelHandoffFiles(
|
||||
["review", "file://src/a.ts", "file://src/b.ts"],
|
||||
(tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
|
||||
(path) => (path.endsWith("a.ts") ? { start: 2, end: 4 } : { startLine: 2, endLine: 4 }),
|
||||
),
|
||||
).toEqual({ "src/a.ts": { start: 2, end: 4 }, "src/b.ts": null })
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
import { createComponent, createEffect, createMemo, type Accessor, type Component, type JSX } from "solid-js"
|
||||
import type { SelectedLineRange } from "@/context/file"
|
||||
import { SESSION_OPEN_FILE_TAB, createOpenSessionFileTab, createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
type TreeMode = "changes" | "all"
|
||||
|
||||
type Input = {
|
||||
currentTab: Accessor<string | undefined>
|
||||
allTabs: Accessor<string[]>
|
||||
openTab: (tab: string) => void
|
||||
preview: (tab: string) => void
|
||||
setActive: (tab: string) => void
|
||||
normalizeFileTab: (tab: string) => string
|
||||
pathFromTab: (tab: string) => string | undefined
|
||||
loadFile: (path: string) => void
|
||||
reviewEnabled: Accessor<boolean>
|
||||
canReview: Accessor<boolean>
|
||||
fileBrowserEnabled: Accessor<boolean>
|
||||
reviewPanelOpened: Accessor<boolean>
|
||||
openReviewPanel: () => void
|
||||
treeMode: Accessor<TreeMode>
|
||||
setTreeMode: (mode: TreeMode) => void
|
||||
fileReady: Accessor<boolean>
|
||||
sessionKey: Accessor<string>
|
||||
selectedLines: (path: string) => unknown
|
||||
persistHandoff: (key: string, files: Record<string, SelectedLineRange | null>) => void
|
||||
showDialog: (render: () => JSX.Element) => void
|
||||
loadSelectFileDialog?: () => Promise<{
|
||||
DialogSelectFile: Component<{ mode?: "files"; onOpenFile?: (path: string) => void }>
|
||||
}>
|
||||
}
|
||||
|
||||
export function createSessionSidePanelController(input: Input) {
|
||||
const normalizeTab = (tab: string) => (tab.startsWith("file://") ? input.normalizeFileTab(tab) : tab)
|
||||
const openReviewPanel = () => {
|
||||
if (!input.reviewPanelOpened()) input.openReviewPanel()
|
||||
}
|
||||
const tabs = createSessionTabs({
|
||||
tabs: () => ({ active: input.currentTab, all: input.allTabs }),
|
||||
pathFromTab: input.pathFromTab,
|
||||
normalizeTab,
|
||||
review: input.reviewEnabled,
|
||||
hasReview: input.canReview,
|
||||
fileBrowser: input.fileBrowserEnabled,
|
||||
})
|
||||
const prepareTab = (tab: string) => {
|
||||
const path = input.pathFromTab(tab)
|
||||
if (path) input.loadFile(path)
|
||||
openReviewPanel()
|
||||
return tab
|
||||
}
|
||||
const open = createOpenSessionFileTab({
|
||||
normalizeTab,
|
||||
openTab: input.openTab,
|
||||
pathFromTab: input.pathFromTab,
|
||||
loadFile: input.loadFile,
|
||||
openReviewPanel,
|
||||
setActive: input.setActive,
|
||||
})
|
||||
const preview = (value: string) => {
|
||||
const next = normalizeTab(value)
|
||||
input.preview(next)
|
||||
const selected = prepareTab(next)
|
||||
queueMicrotask(() => input.setActive(selected))
|
||||
}
|
||||
const activate = (value: string) => input.setActive(prepareTab(normalizeTab(value)))
|
||||
const openFileBrowser = () => preview(SESSION_OPEN_FILE_TAB)
|
||||
const browserTab = createMemo(() => {
|
||||
if (!input.fileBrowserEnabled()) return undefined
|
||||
const active = tabs.activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && input.pathFromTab(active)) return active
|
||||
return tabs.activeFileTab()
|
||||
})
|
||||
// Keep the shell mounted while any file tab exists. Kobalte briefly selects
|
||||
// Review while replacing a preview trigger, which must not reset sidebar scroll.
|
||||
const fileBrowserMounted = createMemo(
|
||||
() =>
|
||||
input.fileBrowserEnabled() && (tabs.openedTabs().length > 0 || tabs.openFileOpen() || browserTab() !== undefined),
|
||||
)
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = tabs.activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const setTreeMode = (value: string) => {
|
||||
if (value !== "changes" && value !== "all") return
|
||||
input.setTreeMode(value)
|
||||
}
|
||||
const showAllFiles = () => {
|
||||
if (input.treeMode() !== "changes") return
|
||||
input.setTreeMode("all")
|
||||
}
|
||||
const openFileDialog = async () => {
|
||||
const load = input.loadSelectFileDialog ?? (() => import("@/components/dialog-select-file"))
|
||||
const { DialogSelectFile } = await load()
|
||||
input.showDialog(() => createComponent(DialogSelectFile, { mode: "files", onOpenFile: showAllFiles }))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.fileReady()) return
|
||||
input.persistHandoff(
|
||||
input.sessionKey(),
|
||||
sessionSidePanelHandoffFiles(input.allTabs(), input.pathFromTab, input.selectedLines),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
tabs: {
|
||||
...tabs,
|
||||
normalize: normalizeTab,
|
||||
open,
|
||||
preview,
|
||||
activate,
|
||||
},
|
||||
browser: {
|
||||
tab: browserTab,
|
||||
mounted: fileBrowserMounted,
|
||||
visible: fileBrowserVisible,
|
||||
open: openFileBrowser,
|
||||
},
|
||||
tree: {
|
||||
mode: input.treeMode,
|
||||
setMode: setTreeMode,
|
||||
showAll: showAllFiles,
|
||||
},
|
||||
dialog: {
|
||||
openFile: openFileDialog,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionSidePanelHandoffFiles(
|
||||
tabs: readonly string[],
|
||||
pathFromTab: (tab: string) => string | undefined,
|
||||
selectedLines: (path: string) => unknown,
|
||||
) {
|
||||
return tabs.reduce<Record<string, SelectedLineRange | null>>((files, tab) => {
|
||||
const path = pathFromTab(tab)
|
||||
if (!path) return files
|
||||
const selected = selectedLines(path)
|
||||
files[path] = isSelectedLineRange(selected) ? selected : null
|
||||
return files
|
||||
}, {})
|
||||
}
|
||||
|
||||
function isSelectedLineRange(value: unknown): value is SelectedLineRange {
|
||||
return !!value && typeof value === "object" && "start" in value && "end" in value
|
||||
}
|
||||
|
||||
export type SessionSidePanelController = ReturnType<typeof createSessionSidePanelController>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { DragDropProvider as DndKitProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -38,17 +38,23 @@ const fileBrowserTabPanelID = "session-side-panel-file-browser-tabpanel"
|
||||
import { SessionContextTab, SortableTab, SortableTabV2, FileVisual } from "@/components/session"
|
||||
import { OpenInAppV2 } from "@/components/session/open-in-app-v2"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { SESSION_OPEN_FILE_TAB, getTabReorderIndex, shouldShowFileTree, type Sizing } from "@/pages/session/helpers"
|
||||
import {
|
||||
SESSION_OPEN_FILE_TAB,
|
||||
createOpenSessionFileTab,
|
||||
createSessionTabs,
|
||||
getTabReorderIndex,
|
||||
shouldShowFileTree,
|
||||
type Sizing,
|
||||
} from "@/pages/session/helpers"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionSidePanelController } from "@/pages/session/session-side-panel-controller"
|
||||
import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff
|
||||
@@ -147,49 +153,91 @@ export function SessionSidePanel(props: {
|
||||
return file.tree.children("").length === 0
|
||||
})
|
||||
|
||||
const controller = createSessionSidePanelController({
|
||||
currentTab: () => tabs().active(),
|
||||
allTabs: () => tabs().all(),
|
||||
openTab: (tab) => tabs().open(tab),
|
||||
preview: (tab) => tabs().previewTab(tab),
|
||||
setActive: (tab) => tabs().setActive(tab),
|
||||
normalizeFileTab: file.tab,
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
}
|
||||
|
||||
const openReviewPanel = () => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
}
|
||||
|
||||
const openTab = createOpenSessionFileTab({
|
||||
normalizeTab,
|
||||
openTab: tabs().open,
|
||||
pathFromTab: file.pathFromTab,
|
||||
loadFile: file.load,
|
||||
reviewEnabled: reviewTab,
|
||||
canReview: props.canReview,
|
||||
fileBrowserEnabled: () => !!props.fileBrowserState,
|
||||
reviewPanelOpened: () => view().reviewPanel.opened(),
|
||||
openReviewPanel: () => view().reviewPanel.open(),
|
||||
treeMode: () => layout.fileTree.tab(),
|
||||
setTreeMode: (mode) => layout.fileTree.setTab(mode),
|
||||
fileReady: file.ready,
|
||||
sessionKey,
|
||||
selectedLines: file.selectedLines,
|
||||
persistHandoff: (key, files) => setSessionHandoff(key, { files }),
|
||||
showDialog: (render) => void dialog.show(render),
|
||||
openReviewPanel,
|
||||
setActive: tabs().setActive,
|
||||
})
|
||||
const contextOpen = controller.tabs.contextOpen
|
||||
const panelTabs = controller.tabs.panelTabs
|
||||
const openedTabs = controller.tabs.openedTabs
|
||||
const activeTab = controller.tabs.activeTab
|
||||
const activeFileTab = controller.tabs.activeFileTab
|
||||
const openTab = controller.tabs.open
|
||||
const previewTab = controller.tabs.preview
|
||||
const activateTab = controller.tabs.activate
|
||||
const browserTab = controller.browser.tab
|
||||
const fileBrowserMounted = controller.browser.mounted
|
||||
const fileBrowserVisible = controller.browser.visible
|
||||
const fileTreeTab = controller.tree.mode
|
||||
const setFileTreeTabValue = controller.tree.setMode
|
||||
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview: props.canReview,
|
||||
fileBrowser: () => !!props.fileBrowserState,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openFileOpen = tabState.openFileOpen
|
||||
const panelTabs = tabState.panelTabs
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
|
||||
const setFileTreeTabValue = (value: string) => {
|
||||
if (value !== "changes" && value !== "all") return
|
||||
layout.fileTree.setTab(value)
|
||||
}
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (fileTreeTab() !== "changes") return
|
||||
layout.fileTree.setTab("all")
|
||||
}
|
||||
|
||||
let fileFilter: HTMLInputElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
const temporaryTab = tabs().preview
|
||||
const previewTab = (value: string) => {
|
||||
const next = normalizeTab(value)
|
||||
tabs().previewTab(next)
|
||||
const path = file.pathFromTab(next)
|
||||
if (path) void file.load(path)
|
||||
openReviewPanel()
|
||||
queueMicrotask(() => tabs().setActive(next))
|
||||
}
|
||||
const openFileBrowser = () => {
|
||||
controller.browser.open()
|
||||
previewTab(SESSION_OPEN_FILE_TAB)
|
||||
queueMicrotask(() => fileFilter?.focus())
|
||||
}
|
||||
const activateTab = (value: string) => {
|
||||
const next = normalizeTab(value)
|
||||
const path = file.pathFromTab(next)
|
||||
if (path) void file.load(path)
|
||||
openReviewPanel()
|
||||
tabs().setActive(next)
|
||||
}
|
||||
const browserTab = createMemo(() => {
|
||||
if (!props.fileBrowserState) return undefined
|
||||
const active = activeTab()
|
||||
if (active === SESSION_OPEN_FILE_TAB) return SESSION_OPEN_FILE_TAB
|
||||
if (active && file.pathFromTab(active)) return active
|
||||
return activeFileTab()
|
||||
})
|
||||
// Keep the file-browser shell mounted while any file tab exists. Kobalte briefly
|
||||
// selects Review while the tab For replaces a preview trigger, which would
|
||||
// otherwise dispose the sidebar and reset scroll.
|
||||
const fileBrowserMounted = createMemo(() => {
|
||||
if (!props.fileBrowserState) return false
|
||||
return openedTabs().length > 0 || openFileOpen() || !!browserTab()
|
||||
})
|
||||
const fileBrowserVisible = createMemo(() => {
|
||||
const active = activeTab()
|
||||
return active !== "review" && active !== "context" && active !== "empty"
|
||||
})
|
||||
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
|
||||
const closeTabKeybind = createMemo(() => command.keybindParts("tab.close"))
|
||||
const [store, setStore] = createStore({
|
||||
@@ -216,6 +264,27 @@ export function SessionSidePanel(props: {
|
||||
setStore("activeDraggable", undefined)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
|
||||
setSessionHandoff(sessionKey(), {
|
||||
files: tabs()
|
||||
.all()
|
||||
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return acc
|
||||
|
||||
const selected = file.selectedLines(path)
|
||||
acc[path] =
|
||||
selected && typeof selected === "object" && "start" in selected && "end" in selected
|
||||
? (selected as SelectedLineRange)
|
||||
: null
|
||||
|
||||
return acc
|
||||
}, {}),
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
|
||||
<aside
|
||||
@@ -382,7 +451,9 @@ export function SessionSidePanel(props: {
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() => {
|
||||
void controller.dialog.openFile()
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { type Accessor, For, Show, createMemo } from "solid-js"
|
||||
import type { ServerCollectionController } from "@/components/server/server-management-controller"
|
||||
import type { useServerManagementController } from "@/components/dialog-select-server"
|
||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
@@ -17,6 +17,8 @@ import { DialogAddWslServer } from "./dialog-add-server"
|
||||
import { useWslServers } from "./context"
|
||||
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
|
||||
|
||||
type Controller = ReturnType<typeof useServerManagementController>
|
||||
|
||||
export function isWslServer(server: ServerConnection.Any) {
|
||||
return server.type === "sidecar" && server.variant === "wsl"
|
||||
}
|
||||
@@ -26,7 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const openAddWsl = () => {
|
||||
void dialog.push(() => <DialogAddWslServer />)
|
||||
dialog.push(() => <DialogAddWslServer />)
|
||||
}
|
||||
return (
|
||||
<Show
|
||||
@@ -65,7 +67,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
|
||||
}
|
||||
|
||||
export function WslServerSettings(props: {
|
||||
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
|
||||
controller: Controller
|
||||
servers: ReturnType<typeof useFilteredWslServers>
|
||||
}) {
|
||||
const platform = usePlatform()
|
||||
@@ -84,7 +86,7 @@ export function WslServerSettings(props: {
|
||||
}))
|
||||
|
||||
const remove = (key: ServerConnection.Key) => {
|
||||
request.mutate(() => props.domain.connection.remove(key))
|
||||
request.mutate(() => props.controller.handleRemove(key))
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -98,7 +100,7 @@ export function WslServerSettings(props: {
|
||||
return (
|
||||
<div class="settings-v2-servers-row">
|
||||
<div class="settings-v2-servers-lead">
|
||||
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
|
||||
<ServerHealthIndicator health={props.controller.status()[key]} />
|
||||
<div class="settings-v2-servers-copy">
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span class="settings-v2-servers-name">{item.config.distro}</span>
|
||||
@@ -112,7 +114,7 @@ export function WslServerSettings(props: {
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-v2-servers-actions">
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<Tag>{language.t("dialog.server.status.default")}</Tag>
|
||||
</Show>
|
||||
<Show when={opencodeAction()}>
|
||||
@@ -143,13 +145,13 @@ export function WslServerSettings(props: {
|
||||
{language.t("wsl.server.retryStart")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
|
||||
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
|
||||
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
|
||||
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "@opencode-ai/llm",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.4",
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.5",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
@@ -80,7 +81,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@ff-labs/fff-bun": "0.9.4",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.5",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
|
||||
@@ -2,13 +2,10 @@ import { cmd } from "./cmd"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { effectCmd } from "../effect-cmd"
|
||||
import { Cause } from "effect"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { MCP } from "../../mcp"
|
||||
import { CLIENT_OPTIONS, MCP } from "../../mcp"
|
||||
import { McpAuth } from "../../mcp/auth"
|
||||
import { McpOAuthProvider } from "../../mcp/oauth-provider"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -731,107 +728,53 @@ export const McpDebugCommand = effectCmd({
|
||||
const spinner = prompts.spinner()
|
||||
spinner.start("Testing connection...")
|
||||
|
||||
// Test basic HTTP connectivity first
|
||||
try {
|
||||
const response = await fetch(serverConfig.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...serverConfig.headers,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
let authorizationUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
callbackPort: oauthConfig?.callbackPort,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
authorizationUrl = url
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode-debug", version: InstallationVersion },
|
||||
},
|
||||
id: 1,
|
||||
}),
|
||||
})
|
||||
},
|
||||
auth,
|
||||
)
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
|
||||
})
|
||||
const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS)
|
||||
|
||||
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
|
||||
|
||||
// Check for WWW-Authenticate header
|
||||
const wwwAuth = response.headers.get("www-authenticate")
|
||||
if (wwwAuth) {
|
||||
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth")
|
||||
|
||||
// Try to discover OAuth metadata
|
||||
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
serverName,
|
||||
serverConfig.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
},
|
||||
auth,
|
||||
)
|
||||
|
||||
prompts.log.info("Testing OAuth flow (without completing authorization)...")
|
||||
|
||||
// Try creating transport with auth provider to trigger discovery
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
|
||||
authProvider,
|
||||
requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode-debug",
|
||||
version: InstallationVersion,
|
||||
})
|
||||
await client.connect(transport)
|
||||
prompts.log.success("Connection successful (already authenticated)")
|
||||
await client.close()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
|
||||
// Check if dynamic registration would be attempted
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) {
|
||||
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
} else {
|
||||
prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
}
|
||||
} else {
|
||||
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
} else if (response.status >= 200 && response.status < 300) {
|
||||
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
|
||||
const body = await response.text()
|
||||
try {
|
||||
const json = JSON.parse(body)
|
||||
if (json.result?.serverInfo) {
|
||||
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, ignore
|
||||
}
|
||||
} else {
|
||||
prompts.log.warn(`Unexpected status: ${response.status}`)
|
||||
const body = await response.text().catch(() => "")
|
||||
if (body) {
|
||||
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.connect(transport)
|
||||
spinner.stop("SDK connection successful")
|
||||
prompts.log.success(
|
||||
`Connected using MCP ${client.getNegotiatedProtocolVersion() ?? "unknown"} (${client.getProtocolEra() ?? "unknown"})`,
|
||||
)
|
||||
const serverInfo = client.getServerVersion()
|
||||
if (serverInfo) prompts.log.info(`Server info: ${JSON.stringify(serverInfo)}`)
|
||||
} catch (error) {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
if (error instanceof UnauthorizedError) {
|
||||
spinner.stop("OAuth required")
|
||||
prompts.log.info(`OAuth flow triggered: ${error.message}`)
|
||||
if (authorizationUrl) prompts.log.info(`Authorization URL: ${authorizationUrl}`)
|
||||
const clientInfo = await authProvider.clientInformation()
|
||||
if (clientInfo) prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
|
||||
if (!clientInfo) prompts.log.info("No client ID - dynamic registration will be attempted")
|
||||
} else {
|
||||
spinner.stop("Connection failed", 1)
|
||||
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => {})
|
||||
}
|
||||
|
||||
prompts.outro("Debug complete")
|
||||
|
||||
@@ -11,6 +11,7 @@ export const Tokens = Schema.Struct({
|
||||
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
scope: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
})
|
||||
export type Tokens = Schema.Schema.Type<typeof Tokens>
|
||||
|
||||
@@ -19,6 +20,9 @@ export const ClientInfo = Schema.Struct({
|
||||
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))),
|
||||
issuer: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)),
|
||||
})
|
||||
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
|
||||
|
||||
|
||||
@@ -1,77 +1,57 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ListToolsResultSchema,
|
||||
ToolSchema,
|
||||
type Tool as MCPToolDef,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client"
|
||||
import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai"
|
||||
import { Effect } from "effect"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
const MAX_LIST_PAGES = 1_000
|
||||
|
||||
const TolerantListToolsResultSchema = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
export interface McpTool {
|
||||
readonly def: MCPToolDef
|
||||
readonly client: Client
|
||||
readonly timeout?: number
|
||||
}
|
||||
|
||||
export async function paginate<T, R extends { nextCursor?: string }>(
|
||||
list: (cursor?: string) => Promise<R>,
|
||||
items: (result: R) => T[],
|
||||
) {
|
||||
const result: T[] = []
|
||||
const cursors = new Set<string>()
|
||||
let cursor: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_LIST_PAGES; page++) {
|
||||
const page = await list(cursor)
|
||||
result.push(...items(page))
|
||||
if (page.nextCursor === undefined) return result
|
||||
if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`)
|
||||
cursors.add(page.nextCursor)
|
||||
cursor = page.nextCursor
|
||||
}
|
||||
|
||||
throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
|
||||
export async function callTool(
|
||||
tool: McpTool,
|
||||
args: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CallToolResult> {
|
||||
const result = await tool.client.callTool(
|
||||
{ name: tool.def.name, arguments: args },
|
||||
{
|
||||
resetTimeoutOnProgress: true,
|
||||
signal,
|
||||
timeout: tool.timeout,
|
||||
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||
onprogress: () => {},
|
||||
},
|
||||
)
|
||||
if (result.isError)
|
||||
throw new Error(
|
||||
result.content
|
||||
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||
.filter((text) => text.trim())
|
||||
.join("\n\n") || "MCP tool returned an error",
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
export function defs(client: Client, timeout?: number) {
|
||||
return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
|
||||
export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool {
|
||||
export function convertTool(tool: McpTool): Tool {
|
||||
const inputSchema: JSONSchema7 = {
|
||||
...(mcpTool.inputSchema as JSONSchema7),
|
||||
...(tool.def.inputSchema as JSONSchema7),
|
||||
type: "object",
|
||||
properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||
properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
return dynamicTool({
|
||||
description: mcpTool.description ?? "",
|
||||
description: tool.def.description ?? "",
|
||||
inputSchema: jsonSchema(inputSchema),
|
||||
execute: async (args: unknown, options) => {
|
||||
const result = await client.callTool(
|
||||
{
|
||||
name: mcpTool.name,
|
||||
arguments: (args || {}) as Record<string, unknown>,
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resetTimeoutOnProgress: true,
|
||||
signal: options.abortSignal,
|
||||
timeout,
|
||||
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||
onprogress: () => {},
|
||||
},
|
||||
)
|
||||
if (result.isError)
|
||||
throw new Error(
|
||||
result.content
|
||||
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||
.filter((text) => text.trim())
|
||||
.join("\n\n") || "MCP tool returned an error",
|
||||
)
|
||||
const result = await callTool(tool, (args || {}) as Record<string, unknown>, options.abortSignal)
|
||||
if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null)
|
||||
return result
|
||||
return {
|
||||
@@ -118,53 +98,26 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name)
|
||||
|
||||
export function prompts(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.prompts) return Promise.resolve([])
|
||||
return paginate(
|
||||
(cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }),
|
||||
(result) => result.prompts,
|
||||
)
|
||||
export async function prompts(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.prompts) return []
|
||||
return (await client.listPrompts(undefined, { timeout })).prompts
|
||||
}
|
||||
|
||||
export function resources(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
|
||||
return paginate(
|
||||
(cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }),
|
||||
(result) => result.resources,
|
||||
)
|
||||
export async function resources(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.resources) return []
|
||||
return (await client.listResources(undefined, { timeout })).resources
|
||||
}
|
||||
|
||||
export function resourceTemplates(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.resources) return Promise.resolve([])
|
||||
return paginate(
|
||||
(cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }),
|
||||
(result) => result.resourceTemplates,
|
||||
)
|
||||
export async function resourceTemplates(client: Client, timeout?: number) {
|
||||
if (!client.getServerCapabilities()?.resources) return []
|
||||
return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates
|
||||
}
|
||||
|
||||
function listTools(client: Client, timeout: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
try {
|
||||
return await client.listTools(params, { timeout })
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error
|
||||
return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout })
|
||||
}
|
||||
},
|
||||
(result) => result.tools,
|
||||
),
|
||||
try: async () => (await client.listTools(undefined, { timeout })).tools,
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
}
|
||||
|
||||
function isOutputSchemaValidationError(error: Error) {
|
||||
return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test(
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
|
||||
export * as McpCatalog from "./catalog"
|
||||
|
||||
@@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
ListRootsRequestSchema,
|
||||
Client,
|
||||
type ClientOptions,
|
||||
StreamableHTTPClientTransport,
|
||||
SSEClientTransport,
|
||||
UnauthorizedError,
|
||||
RegistrationRejectedError,
|
||||
SdkHttpError,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
type Tool as MCPToolDef,
|
||||
ToolListChangedNotificationSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
@@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { McpBrowser } from "./browser"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
const CLIENT_OPTIONS = {
|
||||
export const CLIENT_OPTIONS = {
|
||||
capabilities: {
|
||||
// https://github.com/anomalyco/opencode/issues/11948
|
||||
// sampling: {},
|
||||
@@ -47,6 +47,8 @@ const CLIENT_OPTIONS = {
|
||||
// https://github.com/anomalyco/opencode/issues/28567
|
||||
// tasks: {},
|
||||
},
|
||||
versionNegotiation: { mode: "auto" },
|
||||
listMaxPages: 1_000,
|
||||
} satisfies ClientOptions
|
||||
|
||||
export const Resource = Schema.Struct({
|
||||
@@ -70,13 +72,19 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("MCP
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
type MCPClient = Client
|
||||
type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void }
|
||||
|
||||
function createClient(directory: string) {
|
||||
const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS)
|
||||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
const client: MCPClient = new Client(
|
||||
{ name: "opencode", version: InstallationVersion },
|
||||
{
|
||||
...CLIENT_OPTIONS,
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) },
|
||||
},
|
||||
},
|
||||
)
|
||||
client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] }))
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -154,12 +162,7 @@ export interface ServerInstructions {
|
||||
}
|
||||
|
||||
/** An MCP tool in its native shape; consumers adapt it to their own tool format. */
|
||||
export interface McpTool {
|
||||
/** Shared cached definition; consumers must copy rather than mutate it. */
|
||||
readonly def: MCPToolDef
|
||||
readonly client: MCPClient
|
||||
readonly timeout?: number
|
||||
}
|
||||
export type McpTool = McpCatalog.McpTool
|
||||
|
||||
export interface Interface {
|
||||
readonly status: () => Effect.Effect<Record<string, Status>>
|
||||
@@ -190,7 +193,11 @@ export interface Interface {
|
||||
mcpName: string,
|
||||
onAuthorization?: (authorizationUrl: string) => void,
|
||||
) => Effect.Effect<Status, NotFoundError>
|
||||
readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect<Status, NotFoundError>
|
||||
readonly finishAuth: (
|
||||
mcpName: string,
|
||||
authorizationCode: string,
|
||||
iss?: string,
|
||||
) => Effect.Effect<Status, NotFoundError>
|
||||
readonly removeAuth: (mcpName: string) => Effect.Effect<void>
|
||||
readonly supportsOAuth: (mcpName: string) => Effect.Effect<boolean, NotFoundError>
|
||||
readonly hasStoredTokens: (mcpName: string) => Effect.Effect<boolean>
|
||||
@@ -291,11 +298,18 @@ const layer = Layer.effect(
|
||||
Effect.map((client) => ({ client, transportName: name })),
|
||||
Effect.catch((error) => {
|
||||
const lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const registrationRejected =
|
||||
error instanceof RegistrationRejectedError ||
|
||||
lastError.message.includes("registration") ||
|
||||
lastError.message.includes("client_id")
|
||||
const isAuthError =
|
||||
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
|
||||
error instanceof UnauthorizedError ||
|
||||
registrationRejected ||
|
||||
(authProvider && error instanceof SdkHttpError && error.status === 401) ||
|
||||
(authProvider && lastError.message.includes("OAuth"))
|
||||
|
||||
if (isAuthError) {
|
||||
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
||||
if (registrationRejected) {
|
||||
lastStatus = {
|
||||
status: "needs_client_registration" as const,
|
||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||
@@ -454,12 +468,16 @@ const layer = Layer.effect(
|
||||
)
|
||||
}
|
||||
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) =>
|
||||
client.setNotificationHandler("notifications/message", (notification) =>
|
||||
bridge.promise(serverLog(name, notification.params)),
|
||||
)
|
||||
|
||||
if (!client.getServerCapabilities()?.tools) return
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||
client.onToolsChanged = async (error) => {
|
||||
if (error) {
|
||||
await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message }))
|
||||
return
|
||||
}
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
const listed = await bridge.promise(McpCatalog.defs(client, timeout))
|
||||
@@ -468,7 +486,7 @@ const layer = Layer.effect(
|
||||
|
||||
s.defs[name] = listed
|
||||
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function serverLog(name: string, params: LoggingMessageNotification["params"]) {
|
||||
@@ -904,7 +922,7 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const code = yield* Effect.promise(() => callbackPromise)
|
||||
const callback = yield* Effect.promise(() => callbackPromise)
|
||||
|
||||
const storedState = yield* auth.getOAuthState(mcpName)
|
||||
if (storedState !== result.oauthState) {
|
||||
@@ -912,16 +930,20 @@ const layer = Layer.effect(
|
||||
throw new Error("OAuth state mismatch - potential CSRF attack")
|
||||
}
|
||||
yield* auth.clearOAuthState(mcpName)
|
||||
return yield* finishAuth(mcpName, code)
|
||||
return yield* finishAuth(mcpName, callback.code, callback.iss)
|
||||
})
|
||||
|
||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) {
|
||||
const finishAuth = Effect.fn("MCP.finishAuth")(function* (
|
||||
mcpName: string,
|
||||
authorizationCode: string,
|
||||
iss?: string,
|
||||
) {
|
||||
yield* requireMcpConfig(mcpName)
|
||||
const pending = pendingOAuthTransports.get(mcpName)
|
||||
if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
|
||||
const error = yield* Effect.tryPromise({
|
||||
try: () => pending.transport.finishAuth(authorizationCode),
|
||||
try: () => pending.transport.finishAuth(authorizationCode, iss),
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.match({
|
||||
|
||||
@@ -9,8 +9,13 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1"
|
||||
let currentPort = OAUTH_CALLBACK_PORT
|
||||
let currentPath = OAUTH_CALLBACK_PATH
|
||||
|
||||
export interface AuthorizationCallback {
|
||||
code: string
|
||||
iss?: string
|
||||
}
|
||||
|
||||
interface PendingAuth {
|
||||
resolve: (code: string) => void
|
||||
resolve: (callback: AuthorizationCallback) => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
@@ -49,6 +54,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const iss = url.searchParams.get("iss") ?? undefined
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
@@ -95,7 +101,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http").
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
cleanupStateIndex(state)
|
||||
pending.resolve(code)
|
||||
pending.resolve({ code, iss })
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(OauthCallbackPage.success({ provider: "MCP" }))
|
||||
@@ -130,7 +136,7 @@ export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
||||
export function waitForCallback(oauthState: string, mcpName?: string): Promise<AuthorizationCallback> {
|
||||
if (mcpName) mcpNameToState.set(mcpName, oauthState)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type {
|
||||
OAuthClientProvider,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
StoredOAuthTokens,
|
||||
StoredOAuthClientInformation,
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { Effect } from "effect"
|
||||
import { McpAuth } from "./auth"
|
||||
|
||||
@@ -23,6 +22,14 @@ export interface McpOAuthCallbacks {
|
||||
onRedirect: (url: URL) => void | Promise<void>
|
||||
}
|
||||
|
||||
function registrationMetadata(info: StoredOAuthClientInformation) {
|
||||
return {
|
||||
clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined,
|
||||
clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined,
|
||||
redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export class McpOAuthProvider implements OAuthClientProvider {
|
||||
constructor(
|
||||
protected mcpName: string,
|
||||
@@ -52,18 +59,21 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
|
||||
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||
if (this.config.clientId) {
|
||||
const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
...(issuer !== undefined ? { issuer } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
// Check stored client info (from dynamic registration)
|
||||
// Use getForUrl to validate credentials are for the current server URL
|
||||
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||
if (entry?.clientInfo) {
|
||||
if (entry.clientInfo.configPreRegistered) return undefined
|
||||
// Check if client secret has expired
|
||||
if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
|
||||
return undefined
|
||||
@@ -71,6 +81,14 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
return {
|
||||
client_id: entry.clientInfo.clientId,
|
||||
client_secret: entry.clientInfo.clientSecret,
|
||||
...(entry.clientInfo.clientIdIssuedAt !== undefined
|
||||
? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt }
|
||||
: {}),
|
||||
...(entry.clientInfo.clientSecretExpiresAt !== undefined
|
||||
? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt }
|
||||
: {}),
|
||||
redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl],
|
||||
...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,22 +96,36 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
return undefined
|
||||
}
|
||||
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
|
||||
if (this.config.clientId && info.client_id === this.config.clientId) {
|
||||
await Effect.runPromise(
|
||||
this.auth.updateClientInfo(
|
||||
this.mcpName,
|
||||
{ clientId: info.client_id, issuer: info.issuer, configPreRegistered: true },
|
||||
this.serverUrl,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const metadata = registrationMetadata(info)
|
||||
await Effect.runPromise(
|
||||
this.auth.updateClientInfo(
|
||||
this.mcpName,
|
||||
{
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
clientIdIssuedAt: info.client_id_issued_at,
|
||||
clientSecretExpiresAt: info.client_secret_expires_at,
|
||||
clientIdIssuedAt: metadata.clientIdIssuedAt,
|
||||
clientSecretExpiresAt: metadata.clientSecretExpiresAt,
|
||||
redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl],
|
||||
issuer: info.issuer,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
async tokens(): Promise<StoredOAuthTokens | undefined> {
|
||||
// Use getForUrl to validate tokens are for the current server URL
|
||||
const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl))
|
||||
if (!entry?.tokens) return undefined
|
||||
@@ -106,18 +138,20 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
|
||||
: undefined,
|
||||
scope: entry.tokens.scope,
|
||||
issuer: entry.tokens.issuer,
|
||||
}
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
|
||||
await Effect.runPromise(
|
||||
this.auth.updateTokens(
|
||||
this.mcpName,
|
||||
{
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined,
|
||||
scope: tokens.scope,
|
||||
issuer: tokens.issuer,
|
||||
},
|
||||
this.serverUrl,
|
||||
),
|
||||
@@ -181,10 +215,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
private pendingClientInfo?: OAuthClientInformationFull
|
||||
private pendingTokens?: OAuthTokens
|
||||
private pendingClientInfo?: StoredOAuthClientInformation
|
||||
private pendingTokens?: StoredOAuthTokens
|
||||
|
||||
override async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
override async clientInformation(): Promise<StoredOAuthClientInformation | undefined> {
|
||||
if (!this.config.clientId) return this.pendingClientInfo
|
||||
return {
|
||||
client_id: this.config.clientId,
|
||||
@@ -192,15 +226,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
}
|
||||
}
|
||||
|
||||
override async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
override async saveClientInformation(info: StoredOAuthClientInformation): Promise<void> {
|
||||
this.pendingClientInfo = info
|
||||
}
|
||||
|
||||
override async tokens(): Promise<OAuthTokens | undefined> {
|
||||
override async tokens(): Promise<StoredOAuthTokens | undefined> {
|
||||
return this.pendingTokens
|
||||
}
|
||||
|
||||
override async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
override async saveTokens(tokens: StoredOAuthTokens): Promise<void> {
|
||||
this.pendingTokens = tokens
|
||||
}
|
||||
|
||||
@@ -211,6 +245,7 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
|
||||
async commit(): Promise<void> {
|
||||
if (!this.pendingTokens) return
|
||||
const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined
|
||||
await Effect.runPromise(
|
||||
this.auth.set(
|
||||
this.mcpName,
|
||||
@@ -218,16 +253,22 @@ export class McpOAuthPendingProvider extends McpOAuthProvider {
|
||||
tokens: {
|
||||
accessToken: this.pendingTokens.access_token,
|
||||
refreshToken: this.pendingTokens.refresh_token,
|
||||
expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined,
|
||||
expiresAt:
|
||||
this.pendingTokens.expires_in !== undefined
|
||||
? Date.now() / 1000 + this.pendingTokens.expires_in
|
||||
: undefined,
|
||||
scope: this.pendingTokens.scope,
|
||||
issuer: this.pendingTokens.issuer,
|
||||
},
|
||||
clientInfo:
|
||||
this.pendingClientInfo && !this.config.clientId
|
||||
? {
|
||||
clientId: this.pendingClientInfo.client_id,
|
||||
clientSecret: this.pendingClientInfo.client_secret,
|
||||
clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at,
|
||||
clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at,
|
||||
clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt,
|
||||
clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt,
|
||||
redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl],
|
||||
issuer: this.pendingClientInfo.issuer,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@@ -516,12 +516,20 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
|
||||
return msgs
|
||||
}
|
||||
|
||||
const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [
|
||||
/gemini-2[.-]5(?:[.-]|$)/,
|
||||
/gemini-3-(?:flash|pro)(?:[.-]|$)/,
|
||||
/gemini-3[.-]1(?:[.-]|$)/,
|
||||
/gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/,
|
||||
]
|
||||
|
||||
export function temperature(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
const id = model.api.id.toLowerCase()
|
||||
if (id.includes("north-mini-code")) return 1.0
|
||||
if (id.includes("qwen")) return 0.55
|
||||
if (id.includes("claude")) return undefined
|
||||
if (id.includes("gemini")) return 1.0
|
||||
if (id.includes("gemini"))
|
||||
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined
|
||||
if (id.includes("glm-4.6")) return 1.0
|
||||
if (id.includes("glm-4.7")) return 1.0
|
||||
if (id.includes("minimax-m2")) return 1.0
|
||||
@@ -536,21 +544,24 @@ export function temperature(model: Provider.Model) {
|
||||
}
|
||||
|
||||
export function topP(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
const id = model.api.id.toLowerCase()
|
||||
if (id.includes("qwen")) return 1
|
||||
if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
|
||||
if (id.includes("gemini"))
|
||||
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined
|
||||
if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) {
|
||||
return 0.95
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function topK(model: Provider.Model) {
|
||||
const id = model.id.toLowerCase()
|
||||
const id = model.api.id.toLowerCase()
|
||||
if (id.includes("minimax-m2")) {
|
||||
if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40
|
||||
return 20
|
||||
}
|
||||
if (id.includes("gemini")) return 64
|
||||
if (id.includes("gemini"))
|
||||
return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ export const AuthStartResponse = Schema.Struct({
|
||||
})
|
||||
export const AuthCallbackPayload = Schema.Struct({
|
||||
code: Schema.String,
|
||||
iss: Schema.optional(Schema.String),
|
||||
})
|
||||
export const AuthRemoveResponse = Schema.Struct({
|
||||
success: Schema.Literal(true),
|
||||
|
||||
@@ -38,7 +38,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler
|
||||
payload: typeof AuthCallbackPayload.Type
|
||||
}) {
|
||||
return yield* mcp
|
||||
.finishAuth(ctx.params.name, ctx.payload.code)
|
||||
.finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss)
|
||||
.pipe(
|
||||
Effect.catchTag("MCP.NotFoundError", (error) =>
|
||||
Effect.fail(
|
||||
|
||||
@@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||
if (flags.experimentalCodeMode) return tools
|
||||
|
||||
for (const [key, entry] of Object.entries(yield* mcp.tools())) {
|
||||
const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout)
|
||||
const item = McpCatalog.convertTool(entry)
|
||||
const execute = item.execute
|
||||
if (!execute) continue
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Tool from "./tool"
|
||||
import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { type CallToolResult } from "@modelcontextprotocol/client"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -145,28 +145,7 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input:
|
||||
)
|
||||
const result: CallToolResult = yield* Effect.gen(function* () {
|
||||
yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||
// Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns.
|
||||
return yield* Effect.promise(async () => {
|
||||
const raw = await input.entry.tool.client.callTool(
|
||||
{ name: input.entry.tool.def.name, arguments: input.args },
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resetTimeoutOnProgress: true,
|
||||
signal: input.ctx.abort,
|
||||
timeout: input.entry.tool.timeout,
|
||||
// The MCP SDK only sends a progress token when this hook is present, enabling timeout resets.
|
||||
onprogress: () => {},
|
||||
},
|
||||
)
|
||||
if (raw.isError)
|
||||
throw new Error(
|
||||
raw.content
|
||||
.flatMap((item) => (item.type === "text" ? [item.text] : []))
|
||||
.filter((text) => text.trim())
|
||||
.join("\n\n") || "MCP tool returned an error",
|
||||
)
|
||||
return raw
|
||||
})
|
||||
return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort))
|
||||
}).pipe(
|
||||
Effect.withSpan("Tool.execute", {
|
||||
attributes: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Server } from "@modelcontextprotocol/server"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"
|
||||
|
||||
if (process.argv.includes("--hang")) {
|
||||
const pidFile = process.env.MCP_LIFECYCLE_PID_FILE
|
||||
@@ -11,7 +10,7 @@ if (process.argv.includes("--hang")) {
|
||||
|
||||
const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, () =>
|
||||
server.setRequestHandler("tools/list", () =>
|
||||
Promise.resolve({
|
||||
tools: [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
|
||||
|
||||
const posts: Array<{ method: string; session: string | null }> = []
|
||||
const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1"
|
||||
let initializeCount = 0
|
||||
let pingCount = 0
|
||||
let replacementStarted!: () => void
|
||||
const replacement = new Promise<void>((resolve) => (replacementStarted = resolve))
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
@@ -17,6 +18,7 @@ const server = Bun.serve({
|
||||
|
||||
if (message.method === "initialize") {
|
||||
initializeCount++
|
||||
if (initializeCount === 2) replacementStarted()
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
@@ -34,7 +36,8 @@ const server = Bun.serve({
|
||||
if (message.method === "notifications/initialized") return new Response(null, { status: 202 })
|
||||
|
||||
pingCount++
|
||||
if (pingCount === 1) return new Response("Session not found", { status: 404 })
|
||||
if (concurrent && pingCount === 2) await replacement
|
||||
if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 })
|
||||
return Response.json({ jsonrpc: "2.0", id: message.id, result: {} })
|
||||
},
|
||||
})
|
||||
@@ -42,7 +45,8 @@ const client = new Client({ name: "test", version: "1" })
|
||||
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(server.url))
|
||||
await client.ping()
|
||||
if (concurrent) await Promise.all([client.ping(), client.ping()])
|
||||
else await client.ping()
|
||||
process.stdout.write(JSON.stringify(posts))
|
||||
} finally {
|
||||
await client.close()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client"
|
||||
import { Server } from "@modelcontextprotocol/server"
|
||||
import { McpCatalog } from "@/mcp/catalog"
|
||||
import { Effect } from "effect"
|
||||
|
||||
@@ -30,7 +28,10 @@ describe("McpCatalog.convertTool", () => {
|
||||
test("preserves content when structuredContent is also present", async () => {
|
||||
const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }]
|
||||
const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } }
|
||||
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent }))
|
||||
const converted = McpCatalog.convertTool({
|
||||
def: mcpTool(),
|
||||
client: clientReturning({ content, structuredContent }),
|
||||
})
|
||||
|
||||
const output = await converted.execute?.({}, options)
|
||||
|
||||
@@ -39,7 +40,10 @@ describe("McpCatalog.convertTool", () => {
|
||||
|
||||
test("falls back to structuredContent only when content is absent", async () => {
|
||||
const structuredContent = { results: [{ title: "one" }] }
|
||||
const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent }))
|
||||
const converted = McpCatalog.convertTool({
|
||||
def: mcpTool(),
|
||||
client: clientReturning({ content: [], structuredContent }),
|
||||
})
|
||||
|
||||
const output = await converted.execute?.({}, options)
|
||||
|
||||
@@ -50,18 +54,52 @@ describe("McpCatalog.convertTool", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("McpCatalog.callTool", () => {
|
||||
test("forwards the request options", async () => {
|
||||
const controller = new AbortController()
|
||||
let request: unknown
|
||||
let options: unknown
|
||||
const client = {
|
||||
callTool: async (input: unknown, config: unknown) => {
|
||||
request = input
|
||||
options = config
|
||||
return { content: [] }
|
||||
},
|
||||
} as unknown as Client
|
||||
|
||||
await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal)
|
||||
|
||||
expect(request).toEqual({ name: "screenshot", arguments: { value: true } })
|
||||
expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 })
|
||||
expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function")
|
||||
})
|
||||
|
||||
test("throws text returned by an MCP tool error", async () => {
|
||||
const client = clientReturning({
|
||||
isError: true,
|
||||
content: [
|
||||
{ type: "image", data: "AAAA", mimeType: "image/png" },
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
],
|
||||
})
|
||||
|
||||
await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond")
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
server.setRequestHandler("tools/list", ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: "second",
|
||||
inputSchema: { type: "object" },
|
||||
inputSchema: { type: "object" as const },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
type: "object" as const,
|
||||
properties: { value: { type: "number" } },
|
||||
required: ["value"],
|
||||
},
|
||||
@@ -72,9 +110,9 @@ test("preserves output schema validation across paginated tool discovery", async
|
||||
tools: [
|
||||
{
|
||||
name: "first",
|
||||
inputSchema: { type: "object" },
|
||||
inputSchema: { type: "object" as const },
|
||||
outputSchema: {
|
||||
type: "object",
|
||||
type: "object" as const,
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
},
|
||||
@@ -84,7 +122,7 @@ test("preserves output schema validation across paginated tool discovery", async
|
||||
},
|
||||
),
|
||||
)
|
||||
server.setRequestHandler(CallToolRequestSchema, ({ params }) =>
|
||||
server.setRequestHandler("tools/call", ({ params }) =>
|
||||
Promise.resolve({
|
||||
content: [],
|
||||
structuredContent: { value: params.name === "first" ? 42 : 1 },
|
||||
@@ -98,9 +136,7 @@ test("preserves output schema validation across paginated tool discovery", async
|
||||
try {
|
||||
const tools = await Effect.runPromise(McpCatalog.defs(client))
|
||||
expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"])
|
||||
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(
|
||||
"Structured content does not match the tool's output schema",
|
||||
)
|
||||
await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i)
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()])
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -13,7 +11,7 @@ const serve = Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const requests: Headers[] = []
|
||||
const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
@@ -38,6 +36,11 @@ const serve = Effect.acquireRelease(
|
||||
(server) => Effect.promise(server.close),
|
||||
)
|
||||
|
||||
const serveUnauthorized = Effect.acquireRelease(
|
||||
Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })),
|
||||
(server) => Effect.sync(() => server.stop(true)),
|
||||
)
|
||||
|
||||
describe("mcp.headers", () => {
|
||||
it.instance("headers are passed to transports when oauth is enabled (default)", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -99,4 +102,18 @@ describe("mcp.headers", () => {
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("reports 401 as failed when oauth is explicitly disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* serveUnauthorized
|
||||
const mcp = yield* MCP.Service
|
||||
const result = yield* mcp.add("unauthorized-no-oauth", {
|
||||
type: "remote",
|
||||
url: server.url.toString(),
|
||||
oauth: false,
|
||||
})
|
||||
|
||||
expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { expect } from "bun:test"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import {
|
||||
GetPromptRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListResourceTemplatesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
Server,
|
||||
WebStandardStreamableHTTPServerTransport,
|
||||
type ServerCapabilities,
|
||||
type Tool,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
} from "@modelcontextprotocol/server"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import type { MCP as MCPNS } from "../../src/mcp/index"
|
||||
@@ -66,35 +60,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio
|
||||
})
|
||||
|
||||
if (capabilities.tools) {
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, (request) => {
|
||||
protocol.setRequestHandler("tools/list", (request) => {
|
||||
if (state.listToolsError) throw new Error(state.listToolsError)
|
||||
const page = state.toolPages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor })
|
||||
})
|
||||
}
|
||||
if (capabilities.prompts) {
|
||||
protocol.setRequestHandler(ListPromptsRequestSchema, (request) => {
|
||||
protocol.setRequestHandler("prompts/list", (request) => {
|
||||
const page = state.promptPages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor })
|
||||
})
|
||||
protocol.setRequestHandler(GetPromptRequestSchema, async () => {
|
||||
protocol.setRequestHandler("prompts/get", async () => {
|
||||
if (state.requestDelay) await Bun.sleep(state.requestDelay)
|
||||
return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] }
|
||||
})
|
||||
}
|
||||
if (capabilities.resources) {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||
protocol.setRequestHandler("resources/list", (request) => {
|
||||
const page = state.resourcePages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor })
|
||||
})
|
||||
protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => {
|
||||
protocol.setRequestHandler("resources/templates/list", (request) => {
|
||||
const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"]
|
||||
return Promise.resolve({
|
||||
resourceTemplates: page?.items ?? state.resourceTemplates,
|
||||
nextCursor: page?.nextCursor,
|
||||
})
|
||||
})
|
||||
protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
protocol.setRequestHandler("resources/read", async (request) => {
|
||||
if (state.requestDelay) await Bun.sleep(state.requestDelay)
|
||||
return { contents: [{ uri: request.params.uri, text: "resource result" }] }
|
||||
})
|
||||
@@ -145,7 +139,7 @@ function hangingLifecycleServer() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
@@ -284,7 +278,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("accepts empty cursors and rejects repeated cursors", () =>
|
||||
it.instance("accepts empty cursors and terminates on repeated cursors", () =>
|
||||
Effect.gen(function* () {
|
||||
const empty = yield* lifecycleServer({ capabilities: { prompts: {} } })
|
||||
empty.state.promptPages = {
|
||||
@@ -301,7 +295,8 @@ it.instance("accepts empty cursors and rejects repeated cursors", () =>
|
||||
const result = yield* mcp.add("looping-cursor", remote(looping.url))
|
||||
|
||||
expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"])
|
||||
expect(statusName(result.status, "looping-cursor")).toBe("failed")
|
||||
expect(statusName(result.status, "looping-cursor")).toBe("connected")
|
||||
expect(Object.keys(yield* mcp.tools())).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
@@ -40,13 +38,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) {
|
||||
let requiresAuth = true
|
||||
|
||||
if (capabilities === "tools") {
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => {
|
||||
protocol.setRequestHandler("tools/list", () => {
|
||||
listToolsCalls++
|
||||
return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] })
|
||||
})
|
||||
}
|
||||
if (capabilities === "resources") {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, () =>
|
||||
protocol.setRequestHandler("resources/list", () =>
|
||||
Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"
|
||||
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { Config } from "../../src/config/config"
|
||||
@@ -41,7 +39,7 @@ const serveOAuthMcp = Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const requests: Array<{ pathname: string; headers: Headers }> = []
|
||||
const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||
protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] }))
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => {
|
||||
const response = await fetch(`${redirectUri}?code=code&state=success`)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await callback).toBe("code")
|
||||
expect(await callback).toEqual({ code: "code", iss: undefined })
|
||||
expect(McpOAuthCallback.isRunning()).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect, describe } from "bun:test"
|
||||
import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider"
|
||||
import type { McpAuth } from "../../src/mcp/auth"
|
||||
|
||||
@@ -60,43 +59,3 @@ describe("McpOAuthProvider.clientMetadata", () => {
|
||||
expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none")
|
||||
})
|
||||
})
|
||||
|
||||
describe("MCP OAuth scope selection", () => {
|
||||
test("adds offline_access when the authorization server and client support refresh tokens", () => {
|
||||
expect(
|
||||
determineScope({
|
||||
resourceMetadata: {
|
||||
resource: "https://mcp.example.com/mcp",
|
||||
scopes_supported: ["resource.read"],
|
||||
},
|
||||
authServerMetadata: {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["resource.read", "offline_access"],
|
||||
},
|
||||
clientMetadata: makeProvider({}).clientMetadata,
|
||||
}),
|
||||
).toBe("resource.read offline_access")
|
||||
})
|
||||
|
||||
test("does not add unsupported authorization server scopes", () => {
|
||||
expect(
|
||||
determineScope({
|
||||
resourceMetadata: {
|
||||
resource: "https://mcp.example.com/mcp",
|
||||
scopes_supported: ["resource.read"],
|
||||
},
|
||||
authServerMetadata: {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["resource.read"],
|
||||
},
|
||||
clientMetadata: makeProvider({}).clientMetadata,
|
||||
}),
|
||||
).toBe("resource.read")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,4 +24,24 @@ describe("mcp session recovery", () => {
|
||||
{ method: "ping", session: "replacement" },
|
||||
])
|
||||
})
|
||||
|
||||
test("retries a concurrent stale response after recovery completes", async () => {
|
||||
const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], {
|
||||
cwd: path.join(import.meta.dir, "../.."),
|
||||
env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [code, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
Bun.readableStreamToText(child.stdout),
|
||||
Bun.readableStreamToText(child.stderr),
|
||||
])
|
||||
|
||||
expect(code, stderr).toBe(0)
|
||||
const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }>
|
||||
expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null])
|
||||
expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2)
|
||||
expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3186,7 +3186,69 @@ describe("ProviderTransform.message - cache control on gateway", () => {
|
||||
|
||||
describe("ProviderTransform.temperature - Cohere North", () => {
|
||||
test("defaults north-mini-code models to 1.0", () => {
|
||||
expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0)
|
||||
expect(
|
||||
ProviderTransform.temperature({
|
||||
id: "cohere/North-Mini-Code-1-0-latest",
|
||||
api: { id: "North-Mini-Code-1-0-latest" },
|
||||
} as any),
|
||||
).toBe(1.0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform sampling defaults - Gemini", () => {
|
||||
const model = (id: string) =>
|
||||
({
|
||||
id: `google/${id}`,
|
||||
api: { id },
|
||||
}) as any
|
||||
|
||||
const alias = (id: string, apiID: string) =>
|
||||
({
|
||||
id,
|
||||
api: { id: apiID },
|
||||
}) as any
|
||||
|
||||
test.each([
|
||||
"gemini-3.5-flash-lite",
|
||||
"gemini-3-5-flash-lite",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3-6-flash",
|
||||
"gemini-4-pro",
|
||||
"gemini-future",
|
||||
])("omits deprecated sampling controls for %s", (id) => {
|
||||
expect(ProviderTransform.temperature(model(id))).toBeUndefined()
|
||||
expect(ProviderTransform.topP(model(id))).toBeUndefined()
|
||||
expect(ProviderTransform.topK(model(id))).toBeUndefined()
|
||||
})
|
||||
|
||||
test.each([
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2-5-flash-lite",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3-pro-image",
|
||||
"gemini-3.1-flash-lite",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3-1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
"gemini-3-5-flash",
|
||||
])("preserves sampling defaults for %s", (id) => {
|
||||
expect(ProviderTransform.temperature(model(id))).toBe(1)
|
||||
expect(ProviderTransform.topP(model(id))).toBe(0.95)
|
||||
expect(ProviderTransform.topK(model(id))).toBe(64)
|
||||
})
|
||||
|
||||
test("uses the API model ID for configured aliases", () => {
|
||||
const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash")
|
||||
expect(ProviderTransform.temperature(deprecated)).toBeUndefined()
|
||||
expect(ProviderTransform.topP(deprecated)).toBeUndefined()
|
||||
expect(ProviderTransform.topK(deprecated)).toBeUndefined()
|
||||
|
||||
const supported = alias("my-gemini", "google/gemini-2.5-flash")
|
||||
expect(ProviderTransform.temperature(supported)).toBe(1)
|
||||
expect(ProviderTransform.topP(supported)).toBe(0.95)
|
||||
expect(ProviderTransform.topK(supported)).toBe(64)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,15 +8,14 @@ import { Session } from "@/session/session"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"
|
||||
import type { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { Server } from "@modelcontextprotocol/server"
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
InMemoryTransport,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListToolsRequestSchema,
|
||||
type CallToolResult,
|
||||
type Client,
|
||||
type Tool as MCPToolDef,
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
|
||||
const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||
@@ -100,7 +99,7 @@ const TOOL_DEFS: MCPToolDef[] = [
|
||||
},
|
||||
] as MCPToolDef[]
|
||||
|
||||
function handleCall(name: string, args: Record<string, unknown>) {
|
||||
function handleCall(name: string, args: Record<string, unknown>): CallToolResult {
|
||||
switch (name) {
|
||||
case "get_text":
|
||||
return { content: [{ type: "text", text: `hello ${args.name}` }] }
|
||||
@@ -122,8 +121,8 @@ let description: string
|
||||
|
||||
async function buildTool() {
|
||||
const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }))
|
||||
server.setRequestHandler(CallToolRequestSchema, async (req) =>
|
||||
server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS }))
|
||||
server.setRequestHandler("tools/call", async (req) =>
|
||||
handleCall(req.params.name, (req.params.arguments ?? {}) as Record<string, unknown>),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/client"
|
||||
import type { PermissionV1 } from "@opencode-ai/core/v1/permission"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { MCP } from "@/mcp"
|
||||
|
||||
@@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { MCP } from "@/mcp"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/client"
|
||||
|
||||
const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2328,6 +2328,7 @@ export class Auth2 extends HeyApiClient {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
code?: string
|
||||
iss?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -2340,6 +2341,7 @@ export class Auth2 extends HeyApiClient {
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
{ in: "body", key: "code" },
|
||||
{ in: "body", key: "iss" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -8563,6 +8563,7 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo
|
||||
export type McpAuthCallbackData = {
|
||||
body?: {
|
||||
code: string
|
||||
iss?: string
|
||||
}
|
||||
path: {
|
||||
name: string
|
||||
|
||||
@@ -3360,6 +3360,9 @@
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"iss": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["code"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370a4553855 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -3154,6 +3154,7 @@ var Client = class extends require_src.Protocol {
|
||||
*/
|
||||
async _connectPlainLegacy(transport, options) {
|
||||
await super.connect(transport);
|
||||
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
if (transport.sessionId !== void 0) {
|
||||
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
|
||||
if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion);
|
||||
@@ -3170,6 +3171,7 @@ var Client = class extends require_src.Protocol {
|
||||
* the handshake; its completion sets the negotiated (legacy) version.
|
||||
*/
|
||||
async _legacyHandshake(transport, options) {
|
||||
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions);
|
||||
try {
|
||||
const offeredVersion = legacyVersions[0];
|
||||
@@ -3208,6 +3210,7 @@ var Client = class extends require_src.Protocol {
|
||||
await super.connect(transport);
|
||||
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
|
||||
if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion);
|
||||
+ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
return;
|
||||
}
|
||||
this._resetConnectionState();
|
||||
@@ -5211,10 +5214,32 @@ var StreamableHTTPClientTransport = class {
|
||||
}
|
||||
}
|
||||
async send(message, options) {
|
||||
- return this._send(message, options, false);
|
||||
+ return this._send(message, options, false, 0, false);
|
||||
+ }
|
||||
+ async _recoverSession(expiredSessionId) {
|
||||
+ if (this._sessionRecovery) return this._sessionRecovery;
|
||||
+ if (!this.onsessionexpired) return false;
|
||||
+ if (this._sessionId !== expiredSessionId) return true;
|
||||
+ this._sessionId = void 0;
|
||||
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true);
|
||||
+ try {
|
||||
+ return await this._sessionRecovery;
|
||||
+ } catch (error) {
|
||||
+ this._sessionId = void 0;
|
||||
+ await this.close();
|
||||
+ throw error;
|
||||
+ } finally {
|
||||
+ this._sessionRecovery = void 0;
|
||||
+ }
|
||||
}
|
||||
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
|
||||
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
|
||||
try {
|
||||
+ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message);
|
||||
+ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message);
|
||||
+ if (this._sessionRecovery && !isHandshake && !isInitialized) {
|
||||
+ await this._sessionRecovery;
|
||||
+ options?.requestSignal?.throwIfAborted();
|
||||
+ }
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
if (resumptionToken) {
|
||||
this._startOrAuthSse({
|
||||
@@ -5226,8 +5251,8 @@ var StreamableHTTPClientTransport = class {
|
||||
}
|
||||
const headers = await this._commonHeaders();
|
||||
this._applyBodyDerivedHeaders(headers, message);
|
||||
- const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message);
|
||||
if (isHandshake) headers.delete("mcp-session-id");
|
||||
+ const requestSessionId = headers.get("mcp-session-id") || void 0;
|
||||
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
|
||||
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
|
||||
headers.set(name, value);
|
||||
@@ -5249,8 +5274,14 @@ var StreamableHTTPClientTransport = class {
|
||||
signal
|
||||
};
|
||||
const response = await (this._fetch ?? fetch)(this._url, init);
|
||||
- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0;
|
||||
+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0;
|
||||
if (!response.ok) {
|
||||
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) {
|
||||
+ if (await this._recoverSession(requestSessionId)) {
|
||||
+ options?.requestSignal?.throwIfAborted();
|
||||
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
|
||||
+ }
|
||||
+ }
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
if (response.headers.has("www-authenticate")) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
@@ -5264,7 +5295,7 @@ var StreamableHTTPClientTransport = class {
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
await response.text?.().catch(() => {});
|
||||
- return this._send(message, options, true, stepUpRetries);
|
||||
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) throw new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
|
||||
@@ -5284,7 +5315,7 @@ var StreamableHTTPClientTransport = class {
|
||||
statusText: response.statusText,
|
||||
text
|
||||
}, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError();
|
||||
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
|
||||
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
|
||||
}
|
||||
}
|
||||
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a7749668c39747 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -3151,6 +3151,7 @@ var Client = class extends Protocol {
|
||||
*/
|
||||
async _connectPlainLegacy(transport, options) {
|
||||
await super.connect(transport);
|
||||
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
if (transport.sessionId !== void 0) {
|
||||
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
|
||||
if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion);
|
||||
@@ -3167,6 +3168,7 @@ var Client = class extends Protocol {
|
||||
* the handshake; its completion sets the negotiated (legacy) version.
|
||||
*/
|
||||
async _legacyHandshake(transport, options) {
|
||||
+ transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions);
|
||||
try {
|
||||
const offeredVersion = legacyVersions[0];
|
||||
@@ -3205,6 +3207,7 @@ var Client = class extends Protocol {
|
||||
await super.connect(transport);
|
||||
const negotiatedProtocolVersion = this._negotiatedProtocolVersion;
|
||||
if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion);
|
||||
+ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options);
|
||||
return;
|
||||
}
|
||||
this._resetConnectionState();
|
||||
@@ -5208,10 +5211,32 @@ var StreamableHTTPClientTransport = class {
|
||||
}
|
||||
}
|
||||
async send(message, options) {
|
||||
- return this._send(message, options, false);
|
||||
+ return this._send(message, options, false, 0, false);
|
||||
+ }
|
||||
+ async _recoverSession(expiredSessionId) {
|
||||
+ if (this._sessionRecovery) return this._sessionRecovery;
|
||||
+ if (!this.onsessionexpired) return false;
|
||||
+ if (this._sessionId !== expiredSessionId) return true;
|
||||
+ this._sessionId = void 0;
|
||||
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true);
|
||||
+ try {
|
||||
+ return await this._sessionRecovery;
|
||||
+ } catch (error) {
|
||||
+ this._sessionId = void 0;
|
||||
+ await this.close();
|
||||
+ throw error;
|
||||
+ } finally {
|
||||
+ this._sessionRecovery = void 0;
|
||||
+ }
|
||||
}
|
||||
- async _send(message, options, isAuthRetry, stepUpRetries = 0) {
|
||||
+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) {
|
||||
try {
|
||||
+ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message);
|
||||
+ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message);
|
||||
+ if (this._sessionRecovery && !isHandshake && !isInitialized) {
|
||||
+ await this._sessionRecovery;
|
||||
+ options?.requestSignal?.throwIfAborted();
|
||||
+ }
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
if (resumptionToken) {
|
||||
this._startOrAuthSse({
|
||||
@@ -5223,8 +5248,8 @@ var StreamableHTTPClientTransport = class {
|
||||
}
|
||||
const headers = await this._commonHeaders();
|
||||
this._applyBodyDerivedHeaders(headers, message);
|
||||
- const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message);
|
||||
if (isHandshake) headers.delete("mcp-session-id");
|
||||
+ const requestSessionId = headers.get("mcp-session-id") || void 0;
|
||||
if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) {
|
||||
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
|
||||
headers.set(name, value);
|
||||
@@ -5246,8 +5271,14 @@ var StreamableHTTPClientTransport = class {
|
||||
signal
|
||||
};
|
||||
const response = await (this._fetch ?? fetch)(this._url, init);
|
||||
- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0;
|
||||
+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0;
|
||||
if (!response.ok) {
|
||||
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) {
|
||||
+ if (await this._recoverSession(requestSessionId)) {
|
||||
+ options?.requestSignal?.throwIfAborted();
|
||||
+ return this._send(message, options, isAuthRetry, stepUpRetries, true);
|
||||
+ }
|
||||
+ }
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
if (response.headers.has("www-authenticate")) {
|
||||
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
|
||||
@@ -5261,7 +5292,7 @@ var StreamableHTTPClientTransport = class {
|
||||
fetchFn: this._fetchWithInit
|
||||
});
|
||||
await response.text?.().catch(() => {});
|
||||
- return this._send(message, options, true, stepUpRetries);
|
||||
+ return this._send(message, options, true, stepUpRetries, isSessionRetry);
|
||||
}
|
||||
await response.text?.().catch(() => {});
|
||||
if (isAuthRetry) throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", {
|
||||
@@ -5281,7 +5312,7 @@ var StreamableHTTPClientTransport = class {
|
||||
statusText: response.statusText,
|
||||
text
|
||||
}, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError();
|
||||
- return this._send(message, options, isAuthRetry, stepUpRetries + 1);
|
||||
+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry);
|
||||
}
|
||||
}
|
||||
if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try {
|
||||
@@ -1,629 +0,0 @@
|
||||
diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts
|
||||
index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644
|
||||
--- a/dist/cjs/client/index.d.ts
|
||||
+++ b/dist/cjs/client/index.d.ts
|
||||
@@ -428,6 +428,8 @@ export declare class Client<RequestT extends Request = Request, NotificationT ex
|
||||
*
|
||||
* For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead.
|
||||
*/
|
||||
+ callTool(params: CallToolRequest['params'], resultSchema?: undefined, options?: RequestOptions): Promise<SchemaOutput<typeof CallToolResultSchema>>;
|
||||
+ callTool<T extends typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema>(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>>;
|
||||
callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{
|
||||
[x: string]: unknown;
|
||||
content: ({
|
||||
diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts
|
||||
index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644
|
||||
--- a/dist/esm/client/index.d.ts
|
||||
+++ b/dist/esm/client/index.d.ts
|
||||
@@ -428,6 +428,8 @@ export declare class Client<RequestT extends Request = Request, NotificationT ex
|
||||
*
|
||||
* For task-based execution with streaming behavior, use client.experimental.tasks.callToolStream() instead.
|
||||
*/
|
||||
+ callTool(params: CallToolRequest['params'], resultSchema?: undefined, options?: RequestOptions): Promise<SchemaOutput<typeof CallToolResultSchema>>;
|
||||
+ callTool<T extends typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema>(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise<SchemaOutput<T>>;
|
||||
callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{
|
||||
[x: string]: unknown;
|
||||
content: ({
|
||||
diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js
|
||||
index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644
|
||||
--- a/dist/cjs/client/index.js
|
||||
+++ b/dist/cjs/client/index.js
|
||||
@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol {
|
||||
}
|
||||
async connect(transport, options) {
|
||||
await super.connect(transport);
|
||||
+ transport.onsessionexpired = async () => {
|
||||
+ await this._initialize(transport);
|
||||
+ };
|
||||
// When transport sessionId is already set this means we are trying to reconnect.
|
||||
// In this case we don't need to initialize again.
|
||||
if (transport.sessionId !== undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
- const result = await this.request({
|
||||
- method: 'initialize',
|
||||
- params: {
|
||||
- protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION,
|
||||
- capabilities: this._capabilities,
|
||||
- clientInfo: this._clientInfo
|
||||
- }
|
||||
- }, types_js_1.InitializeResultSchema, options);
|
||||
- if (result === undefined) {
|
||||
- throw new Error(`Server sent invalid initialize result: ${result}`);
|
||||
- }
|
||||
- if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
|
||||
- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
|
||||
- }
|
||||
- this._serverCapabilities = result.capabilities;
|
||||
- this._serverVersion = result.serverInfo;
|
||||
- // HTTP transports must set the protocol version in each header after initialization.
|
||||
- if (transport.setProtocolVersion) {
|
||||
- transport.setProtocolVersion(result.protocolVersion);
|
||||
- }
|
||||
- this._instructions = result.instructions;
|
||||
- await this.notification({
|
||||
- method: 'notifications/initialized'
|
||||
- });
|
||||
- // Set up list changed handlers now that we know server capabilities
|
||||
- if (this._pendingListChangedConfig) {
|
||||
- this._setupListChangedHandlers(this._pendingListChangedConfig);
|
||||
- this._pendingListChangedConfig = undefined;
|
||||
- }
|
||||
+ await this._initialize(transport, options);
|
||||
}
|
||||
catch (error) {
|
||||
// Disconnect if initialization fails.
|
||||
@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+ async _initialize(transport, options) {
|
||||
+ const result = await this.request({
|
||||
+ method: 'initialize',
|
||||
+ params: {
|
||||
+ protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION,
|
||||
+ capabilities: this._capabilities,
|
||||
+ clientInfo: this._clientInfo
|
||||
+ }
|
||||
+ }, types_js_1.InitializeResultSchema, options);
|
||||
+ if (result === undefined) {
|
||||
+ throw new Error(`Server sent invalid initialize result: ${result}`);
|
||||
+ }
|
||||
+ if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
|
||||
+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
|
||||
+ }
|
||||
+ this._serverCapabilities = result.capabilities;
|
||||
+ this._serverVersion = result.serverInfo;
|
||||
+ // HTTP transports must set the protocol version in each header after initialization.
|
||||
+ if (transport.setProtocolVersion) {
|
||||
+ transport.setProtocolVersion(result.protocolVersion);
|
||||
+ }
|
||||
+ this._instructions = result.instructions;
|
||||
+ await this.notification({
|
||||
+ method: 'notifications/initialized'
|
||||
+ });
|
||||
+ // Set up list changed handlers now that we know server capabilities
|
||||
+ if (this._pendingListChangedConfig) {
|
||||
+ this._setupListChangedHandlers(this._pendingListChangedConfig);
|
||||
+ this._pendingListChangedConfig = undefined;
|
||||
+ }
|
||||
+ }
|
||||
/**
|
||||
* After initialization has completed, this will be populated with the server's reported capabilities.
|
||||
*/
|
||||
@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol {
|
||||
* Called after listTools() to pre-compile validators for better performance.
|
||||
*/
|
||||
- cacheToolMetadata(tools) {
|
||||
- this._cachedToolOutputValidators.clear();
|
||||
- this._cachedKnownTaskTools.clear();
|
||||
- this._cachedRequiredTaskTools.clear();
|
||||
+ cacheToolMetadata(tools, reset = true) {
|
||||
+ if (reset) {
|
||||
+ this._cachedToolOutputValidators.clear();
|
||||
+ this._cachedKnownTaskTools.clear();
|
||||
+ this._cachedRequiredTaskTools.clear();
|
||||
+ }
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
if (tool.outputSchema) {
|
||||
@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol {
|
||||
async listTools(params, options) {
|
||||
const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options);
|
||||
// Cache the tools and their output schemas for future validation
|
||||
- this.cacheToolMetadata(result.tools);
|
||||
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js
|
||||
index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644
|
||||
--- a/dist/cjs/client/streamableHttp.js
|
||||
+++ b/dist/cjs/client/streamableHttp.js
|
||||
@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
async send(message, options) {
|
||||
+ return this._send(message, options, false);
|
||||
+ }
|
||||
+ async _recoverSession(expiredSessionId) {
|
||||
+ if (this._sessionRecovery) {
|
||||
+ await this._sessionRecovery;
|
||||
+ return true;
|
||||
+ }
|
||||
+ if (this._sessionId !== expiredSessionId)
|
||||
+ return true;
|
||||
+ this._sessionId = undefined;
|
||||
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
|
||||
try {
|
||||
+ await this._sessionRecovery;
|
||||
+ }
|
||||
+ catch (error) {
|
||||
+ this._sessionId = undefined;
|
||||
+ await this.close();
|
||||
+ throw error;
|
||||
+ }
|
||||
+ finally {
|
||||
+ this._sessionRecovery = undefined;
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
+ async _send(message, options, isSessionRetry) {
|
||||
+ try {
|
||||
+ if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) {
|
||||
+ await this._sessionRecovery;
|
||||
+ if (options?.isRequestActive?.() === false) {
|
||||
+ throw new Error('Request is no longer active');
|
||||
+ }
|
||||
+ }
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
if (resumptionToken) {
|
||||
// If we have at last event ID, we need to reconnect the SSE stream
|
||||
@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport {
|
||||
return;
|
||||
}
|
||||
const headers = await this._commonHeaders();
|
||||
+ const requestSessionId = headers.get('mcp-session-id') ?? undefined;
|
||||
headers.set('content-type', 'application/json');
|
||||
headers.set('accept', 'application/json, text/event-stream');
|
||||
const init = {
|
||||
@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport {
|
||||
const response = await (this._fetch ?? fetch)(this._url, init);
|
||||
// Handle session ID received during initialization
|
||||
const sessionId = response.headers.get('mcp-session-id');
|
||||
- if (sessionId) {
|
||||
+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) {
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => null);
|
||||
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) {
|
||||
+ const recovered = await this._recoverSession(requestSessionId);
|
||||
+ if (options?.isRequestActive?.() === false) {
|
||||
+ throw new Error('Request is no longer active');
|
||||
+ }
|
||||
+ if (recovered) {
|
||||
+ return this._send(message, options, true);
|
||||
+ }
|
||||
+ }
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Prevent infinite recursion when server returns 401 after successful auth
|
||||
if (this._hasCompletedAuthFlow) {
|
||||
@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport {
|
||||
// Mark that we completed auth flow
|
||||
this._hasCompletedAuthFlow = true;
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
- return this.send(message);
|
||||
+ return this._send(message, options, isSessionRetry);
|
||||
}
|
||||
if (response.status === 403 && this._authProvider) {
|
||||
const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response);
|
||||
@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport {
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new auth_js_1.UnauthorizedError();
|
||||
}
|
||||
- return this.send(message);
|
||||
+ return this._send(message, options, isSessionRetry);
|
||||
}
|
||||
}
|
||||
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
|
||||
diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js
|
||||
index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb77538182b 100644
|
||||
--- a/dist/cjs/shared/protocol.js
|
||||
+++ b/dist/cjs/shared/protocol.js
|
||||
@@ -744,7 +744,12 @@ class Protocol {
|
||||
}
|
||||
else {
|
||||
// No related task - send through transport normally
|
||||
- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => {
|
||||
+ this._transport.send(jsonrpcRequest, {
|
||||
+ relatedRequestId,
|
||||
+ resumptionToken,
|
||||
+ onresumptiontoken,
|
||||
+ isRequestActive: () => this._responseHandlers.has(messageId)
|
||||
+ }).catch(error => {
|
||||
this._cleanupTimeout(messageId);
|
||||
reject(error);
|
||||
});
|
||||
diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts
|
||||
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
|
||||
--- a/dist/cjs/client/auth.d.ts
|
||||
+++ b/dist/cjs/client/auth.d.ts
|
||||
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
|
||||
* @returns A Promise that resolves to an OAuthError instance
|
||||
*/
|
||||
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
|
||||
+/**
|
||||
+ * Selects scopes per the MCP spec and augments them for refresh token support.
|
||||
+ */
|
||||
+export declare function determineScope(options: {
|
||||
+ requestedScope?: string;
|
||||
+ resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
+ authServerMetadata?: AuthorizationServerMetadata;
|
||||
+ clientMetadata: OAuthClientMetadata;
|
||||
+}): string | undefined;
|
||||
/**
|
||||
* Orchestrates the full auth flow with a server.
|
||||
*
|
||||
diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js
|
||||
index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644
|
||||
--- a/dist/cjs/client/auth.js
|
||||
+++ b/dist/cjs/client/auth.js
|
||||
@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0;
|
||||
exports.selectClientAuthMethod = selectClientAuthMethod;
|
||||
exports.parseErrorResponse = parseErrorResponse;
|
||||
exports.auth = auth;
|
||||
+exports.determineScope = determineScope;
|
||||
exports.isHttpsUrl = isHttpsUrl;
|
||||
exports.selectResourceURL = selectResourceURL;
|
||||
exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams;
|
||||
@@ -186,6 +187,19 @@ async function auth(provider, options) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+/**
|
||||
+ * Selects scopes per the MCP spec and augments them for refresh token support.
|
||||
+ */
|
||||
+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
|
||||
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
|
||||
+ if (effectiveScope &&
|
||||
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
|
||||
+ !effectiveScope.split(' ').includes('offline_access') &&
|
||||
+ clientMetadata.grant_types?.includes('refresh_token')) {
|
||||
+ effectiveScope = `${effectiveScope} offline_access`;
|
||||
+ }
|
||||
+ return effectiveScope;
|
||||
+}
|
||||
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
|
||||
// Check if the provider has cached discovery state to skip discovery
|
||||
const cachedState = await provider.discoveryState?.();
|
||||
@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
|
||||
});
|
||||
}
|
||||
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
|
||||
- // Apply scope selection strategy (SEP-835):
|
||||
- // 1. WWW-Authenticate scope (passed via `scope` param)
|
||||
- // 2. PRM scopes_supported
|
||||
- // 3. Client metadata scope (user-configured fallback)
|
||||
- // The resolved scope is used consistently for both DCR and the authorization request.
|
||||
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
|
||||
+ const resolvedScope = determineScope({
|
||||
+ requestedScope: scope,
|
||||
+ resourceMetadata,
|
||||
+ authServerMetadata: metadata,
|
||||
+ clientMetadata: provider.clientMetadata
|
||||
+ });
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
if (!clientInformation) {
|
||||
@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo
|
||||
if (scope) {
|
||||
authorizationUrl.searchParams.set('scope', scope);
|
||||
}
|
||||
- if (scope?.includes('offline_access')) {
|
||||
+ if (scope?.split(' ').includes('offline_access')) {
|
||||
// if the request includes the OIDC-only "offline_access" scope,
|
||||
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts
|
||||
index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644
|
||||
--- a/dist/esm/client/auth.d.ts
|
||||
+++ b/dist/esm/client/auth.d.ts
|
||||
@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise<OA
|
||||
* @returns A Promise that resolves to an OAuthError instance
|
||||
*/
|
||||
export declare function parseErrorResponse(input: Response | string): Promise<OAuthError>;
|
||||
+/**
|
||||
+ * Selects scopes per the MCP spec and augments them for refresh token support.
|
||||
+ */
|
||||
+export declare function determineScope(options: {
|
||||
+ requestedScope?: string;
|
||||
+ resourceMetadata?: OAuthProtectedResourceMetadata;
|
||||
+ authServerMetadata?: AuthorizationServerMetadata;
|
||||
+ clientMetadata: OAuthClientMetadata;
|
||||
+}): string | undefined;
|
||||
/**
|
||||
* Orchestrates the full auth flow with a server.
|
||||
*
|
||||
diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js
|
||||
index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644
|
||||
--- a/dist/esm/client/auth.js
|
||||
+++ b/dist/esm/client/auth.js
|
||||
@@ -161,6 +161,19 @@ export async function auth(provider, options) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+/**
|
||||
+ * Selects scopes per the MCP spec and augments them for refresh token support.
|
||||
+ */
|
||||
+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) {
|
||||
+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;
|
||||
+ if (effectiveScope &&
|
||||
+ authServerMetadata?.scopes_supported?.includes('offline_access') &&
|
||||
+ !effectiveScope.split(' ').includes('offline_access') &&
|
||||
+ clientMetadata.grant_types?.includes('refresh_token')) {
|
||||
+ effectiveScope = `${effectiveScope} offline_access`;
|
||||
+ }
|
||||
+ return effectiveScope;
|
||||
+}
|
||||
async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) {
|
||||
// Check if the provider has cached discovery state to skip discovery
|
||||
const cachedState = await provider.discoveryState?.();
|
||||
@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res
|
||||
});
|
||||
}
|
||||
const resource = await selectResourceURL(serverUrl, provider, resourceMetadata);
|
||||
- // Apply scope selection strategy (SEP-835):
|
||||
- // 1. WWW-Authenticate scope (passed via `scope` param)
|
||||
- // 2. PRM scopes_supported
|
||||
- // 3. Client metadata scope (user-configured fallback)
|
||||
- // The resolved scope is used consistently for both DCR and the authorization request.
|
||||
- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
|
||||
+ const resolvedScope = determineScope({
|
||||
+ requestedScope: scope,
|
||||
+ resourceMetadata,
|
||||
+ authServerMetadata: metadata,
|
||||
+ clientMetadata: provider.clientMetadata
|
||||
+ });
|
||||
// Handle client registration if needed
|
||||
let clientInformation = await Promise.resolve(provider.clientInformation());
|
||||
if (!clientInformation) {
|
||||
@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli
|
||||
if (scope) {
|
||||
authorizationUrl.searchParams.set('scope', scope);
|
||||
}
|
||||
- if (scope?.includes('offline_access')) {
|
||||
+ if (scope?.split(' ').includes('offline_access')) {
|
||||
// if the request includes the OIDC-only "offline_access" scope,
|
||||
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
|
||||
diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js
|
||||
index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644
|
||||
--- a/dist/esm/client/index.js
|
||||
+++ b/dist/esm/client/index.js
|
||||
@@ -284,41 +284,16 @@ export class Client extends Protocol {
|
||||
}
|
||||
async connect(transport, options) {
|
||||
await super.connect(transport);
|
||||
+ transport.onsessionexpired = async () => {
|
||||
+ await this._initialize(transport);
|
||||
+ };
|
||||
// When transport sessionId is already set this means we are trying to reconnect.
|
||||
// In this case we don't need to initialize again.
|
||||
if (transport.sessionId !== undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
- const result = await this.request({
|
||||
- method: 'initialize',
|
||||
- params: {
|
||||
- protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
- capabilities: this._capabilities,
|
||||
- clientInfo: this._clientInfo
|
||||
- }
|
||||
- }, InitializeResultSchema, options);
|
||||
- if (result === undefined) {
|
||||
- throw new Error(`Server sent invalid initialize result: ${result}`);
|
||||
- }
|
||||
- if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
|
||||
- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
|
||||
- }
|
||||
- this._serverCapabilities = result.capabilities;
|
||||
- this._serverVersion = result.serverInfo;
|
||||
- // HTTP transports must set the protocol version in each header after initialization.
|
||||
- if (transport.setProtocolVersion) {
|
||||
- transport.setProtocolVersion(result.protocolVersion);
|
||||
- }
|
||||
- this._instructions = result.instructions;
|
||||
- await this.notification({
|
||||
- method: 'notifications/initialized'
|
||||
- });
|
||||
- // Set up list changed handlers now that we know server capabilities
|
||||
- if (this._pendingListChangedConfig) {
|
||||
- this._setupListChangedHandlers(this._pendingListChangedConfig);
|
||||
- this._pendingListChangedConfig = undefined;
|
||||
- }
|
||||
+ await this._initialize(transport, options);
|
||||
}
|
||||
catch (error) {
|
||||
// Disconnect if initialization fails.
|
||||
@@ -326,6 +301,37 @@ export class Client extends Protocol {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
+ async _initialize(transport, options) {
|
||||
+ const result = await this.request({
|
||||
+ method: 'initialize',
|
||||
+ params: {
|
||||
+ protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
+ capabilities: this._capabilities,
|
||||
+ clientInfo: this._clientInfo
|
||||
+ }
|
||||
+ }, InitializeResultSchema, options);
|
||||
+ if (result === undefined) {
|
||||
+ throw new Error(`Server sent invalid initialize result: ${result}`);
|
||||
+ }
|
||||
+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) {
|
||||
+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
|
||||
+ }
|
||||
+ this._serverCapabilities = result.capabilities;
|
||||
+ this._serverVersion = result.serverInfo;
|
||||
+ // HTTP transports must set the protocol version in each header after initialization.
|
||||
+ if (transport.setProtocolVersion) {
|
||||
+ transport.setProtocolVersion(result.protocolVersion);
|
||||
+ }
|
||||
+ this._instructions = result.instructions;
|
||||
+ await this.notification({
|
||||
+ method: 'notifications/initialized'
|
||||
+ });
|
||||
+ // Set up list changed handlers now that we know server capabilities
|
||||
+ if (this._pendingListChangedConfig) {
|
||||
+ this._setupListChangedHandlers(this._pendingListChangedConfig);
|
||||
+ this._pendingListChangedConfig = undefined;
|
||||
+ }
|
||||
+ }
|
||||
/**
|
||||
* After initialization has completed, this will be populated with the server's reported capabilities.
|
||||
*/
|
||||
@@ -537,9 +543,11 @@ export class Client extends Protocol {
|
||||
* Called after listTools() to pre-compile validators for better performance.
|
||||
*/
|
||||
- cacheToolMetadata(tools) {
|
||||
- this._cachedToolOutputValidators.clear();
|
||||
- this._cachedKnownTaskTools.clear();
|
||||
- this._cachedRequiredTaskTools.clear();
|
||||
+ cacheToolMetadata(tools, reset = true) {
|
||||
+ if (reset) {
|
||||
+ this._cachedToolOutputValidators.clear();
|
||||
+ this._cachedKnownTaskTools.clear();
|
||||
+ this._cachedRequiredTaskTools.clear();
|
||||
+ }
|
||||
for (const tool of tools) {
|
||||
// If the tool has an outputSchema, create and cache the validator
|
||||
if (tool.outputSchema) {
|
||||
@@ -565,7 +573,7 @@ export class Client extends Protocol {
|
||||
async listTools(params, options) {
|
||||
const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options);
|
||||
// Cache the tools and their output schemas for future validation
|
||||
- this.cacheToolMetadata(result.tools);
|
||||
+ this.cacheToolMetadata(result.tools, params?.cursor === undefined);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js
|
||||
index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644
|
||||
--- a/dist/esm/client/streamableHttp.js
|
||||
+++ b/dist/esm/client/streamableHttp.js
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js';
|
||||
-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js';
|
||||
import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js';
|
||||
import { EventSourceParserStream } from 'eventsource-parser/stream';
|
||||
// Default reconnection options for StreamableHTTP connections
|
||||
@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport {
|
||||
this.onclose?.();
|
||||
}
|
||||
async send(message, options) {
|
||||
+ return this._send(message, options, false);
|
||||
+ }
|
||||
+ async _recoverSession(expiredSessionId) {
|
||||
+ if (this._sessionRecovery) {
|
||||
+ await this._sessionRecovery;
|
||||
+ return true;
|
||||
+ }
|
||||
+ if (this._sessionId !== expiredSessionId)
|
||||
+ return true;
|
||||
+ this._sessionId = undefined;
|
||||
+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.());
|
||||
try {
|
||||
+ await this._sessionRecovery;
|
||||
+ }
|
||||
+ catch (error) {
|
||||
+ this._sessionId = undefined;
|
||||
+ await this.close();
|
||||
+ throw error;
|
||||
+ }
|
||||
+ finally {
|
||||
+ this._sessionRecovery = undefined;
|
||||
+ }
|
||||
+ return true;
|
||||
+ }
|
||||
+ async _send(message, options, isSessionRetry) {
|
||||
+ try {
|
||||
+ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) {
|
||||
+ await this._sessionRecovery;
|
||||
+ if (options?.isRequestActive?.() === false) {
|
||||
+ throw new Error('Request is no longer active');
|
||||
+ }
|
||||
+ }
|
||||
const { resumptionToken, onresumptiontoken } = options || {};
|
||||
if (resumptionToken) {
|
||||
// If we have at last event ID, we need to reconnect the SSE stream
|
||||
@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport {
|
||||
return;
|
||||
}
|
||||
const headers = await this._commonHeaders();
|
||||
+ const requestSessionId = headers.get('mcp-session-id') ?? undefined;
|
||||
headers.set('content-type', 'application/json');
|
||||
headers.set('accept', 'application/json, text/event-stream');
|
||||
const init = {
|
||||
@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport {
|
||||
const response = await (this._fetch ?? fetch)(this._url, init);
|
||||
// Handle session ID received during initialization
|
||||
const sessionId = response.headers.get('mcp-session-id');
|
||||
- if (sessionId) {
|
||||
+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) {
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => null);
|
||||
+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) {
|
||||
+ const recovered = await this._recoverSession(requestSessionId);
|
||||
+ if (options?.isRequestActive?.() === false) {
|
||||
+ throw new Error('Request is no longer active');
|
||||
+ }
|
||||
+ if (recovered) {
|
||||
+ return this._send(message, options, true);
|
||||
+ }
|
||||
+ }
|
||||
if (response.status === 401 && this._authProvider) {
|
||||
// Prevent infinite recursion when server returns 401 after successful auth
|
||||
if (this._hasCompletedAuthFlow) {
|
||||
@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport {
|
||||
// Mark that we completed auth flow
|
||||
this._hasCompletedAuthFlow = true;
|
||||
// Purposely _not_ awaited, so we don't call onerror twice
|
||||
- return this.send(message);
|
||||
+ return this._send(message, options, isSessionRetry);
|
||||
}
|
||||
if (response.status === 403 && this._authProvider) {
|
||||
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
|
||||
@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport {
|
||||
if (result !== 'AUTHORIZED') {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
- return this.send(message);
|
||||
+ return this._send(message, options, isSessionRetry);
|
||||
}
|
||||
}
|
||||
throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`);
|
||||
diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js
|
||||
index bfa2b7120a0f50c569364ea5264e6f811076f44f..abd8dfd707c155f71dae7aeeeeaf7547368ac749 100644
|
||||
--- a/dist/esm/shared/protocol.js
|
||||
+++ b/dist/esm/shared/protocol.js
|
||||
@@ -740,7 +740,12 @@ export class Protocol {
|
||||
}
|
||||
else {
|
||||
// No related task - send through transport normally
|
||||
- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => {
|
||||
+ this._transport.send(jsonrpcRequest, {
|
||||
+ relatedRequestId,
|
||||
+ resumptionToken,
|
||||
+ onresumptiontoken,
|
||||
+ isRequestActive: () => this._responseHandlers.has(messageId)
|
||||
+ }).catch(error => {
|
||||
this._cleanupTimeout(messageId);
|
||||
reject(error);
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.18.7",
|
||||
"version": "1.18.8",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user