Compare commits

...

44 Commits

Author SHA1 Message Date
Brendan Allan 6fc7affb76 refactor(app): split server management controllers 2026-07-28 09:43:59 +08:00
Brendan Allan 597ae624fc refactor(app): extract v2 settings controllers 2026-07-28 09:43:46 +08:00
Brendan Allan f88e5f526e refactor(app): thin new session composition 2026-07-28 09:43:34 +08:00
Brendan Allan 544559bc07 refactor(app): extract keybind settings controller 2026-07-28 09:43:17 +08:00
Brendan Allan b72d7b6fa2 refactor(app): separate model selector controller and view (#39084) 2026-07-28 01:42:14 +00:00
Vladimir Glafirov 029f5aa3a0 chore: bump gitlab-ai-provider to 6.12.0 (#39096) 2026-07-27 20:40:48 -05:00
Brendan Allan 284e97068c fix(app): use blue for server status attention (#39217) 2026-07-28 01:16:18 +00:00
opencode-agent[bot] 970c38257c chore: generate 2026-07-28 01:11:56 +00:00
Brendan Allan c39ad38424 fix(app): refresh global provider state (#39220) 2026-07-28 01:09:45 +00:00
opencode-agent[bot] 91508f6c88 chore: generate 2026-07-28 00:26:37 +00:00
Andrei Dziahel 1274408b89 fix(desktop): install AppStream metainfo in Linux packages (#36872)
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
2026-07-28 08:24:20 +08:00
Luke Parker 7ae1be5bf6 fix(desktop): use channel database in local runs (#39209) 2026-07-28 10:23:43 +10:00
opencode-agent[bot] 40e4d730ca fix(console): avoid Safari brand icon repaint (#39154)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 15:57:55 -04:00
OpeOginni 8dc539b4ae fix(core): correct MCP environment field in built-in skill (#39175) 2026-07-27 14:52:16 -05:00
Brendan Allan 8e8d38c3cd fix(app): prevent freeze when closing projects (#39158) 2026-07-27 18:40:30 +00:00
Jack a9bebd3653 docs(zen): add Kimi K3 (#39152) 2026-07-28 01:04:04 +08:00
opencode-agent[bot] 78f5764319 fix(console): stabilize brand download icons in Safari (#39151)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
2026-07-27 12:58:19 -04:00
usrnk1 55b0211690 feat(desktop): use bg layer 4 for v2 switch (#39085) 2026-07-27 22:23:54 +08:00
Brendan Allan 4f62295e44 fix(app): contain right panel suspense (#39080) 2026-07-27 09:56:13 +00:00
Brendan Allan e1c41941a9 refactor(app): extract tab rename adapter (#39079) 2026-07-27 09:54:24 +00:00
opencode-agent[bot] 0b4edfc64c chore: generate 2026-07-27 09:17:56 +00:00
Brendan Allan 4469cecc4e refactor(app): move prompt effects into controller (#39073) 2026-07-27 17:15:57 +08:00
Brendan Allan b06768aa8e feat(app): add project selector shortcut (#39074) 2026-07-27 17:15:35 +08:00
opencode bc2d3df05f sync release versions for v1.18.7 2026-07-27 07:32:58 +00:00
Luke Parker 35075bb466 fix(desktop): install Electron before development (#39052) 2026-07-27 14:37:42 +10:00
Luke Parker a9afed0515 fix(desktop): remove titlebar inset in fullscreen (#38793) 2026-07-27 03:54:54 +00:00
Brendan Allan 09afffeead fix(app): preserve shadowed command owners (#39044) 2026-07-27 03:45:18 +00:00
opencode-agent[bot] de78da8a23 chore: generate 2026-07-27 03:06:34 +00:00
Brendan Allan 9ca2b44235 Connect provider e2e test (#39039) 2026-07-27 11:05:21 +08:00
opencode-agent[bot] 7ffc22c0ef chore: generate 2026-07-27 02:49:22 +00:00
David Siewert 759f6afa80 fix(app): add scroll to project selector dropdown (#39016)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:48:01 +08:00
opencode 1ad63cfc6c sync release versions for v1.18.6 2026-07-27 02:47:42 +00:00
Rahul A Mistry 7d8195e8fe fix(ui): keep mutable selects open (#39027)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-27 10:46:59 +08:00
opencode-agent[bot] 7534d23551 chore: update nix node_modules hashes 2026-07-25 08:21:12 +00:00
Brendan Allan 0a6637e17a chore(app): vendor v2 client snapshot (#38818) 2026-07-25 08:07:13 +00:00
Brendan Allan 2b2b69d668 fix(app): refresh V1 MCP state (#38816) 2026-07-25 15:01:18 +08:00
opencode-agent[bot] 9e8b2171a5 fix(app): refresh V1 providers after auth (#38786)
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-25 14:58:05 +08:00
opencode-agent[bot] 5e2a6257b2 chore: generate 2026-07-25 01:25:04 +00:00
Kit Langton 065dc274ec fix(core): branch-keyed repository cache with gated reference readiness (#38759) 2026-07-24 21:23:44 -04:00
opencode a85d8d23aa sync release versions for v1.18.5 2026-07-24 22:18:13 +00:00
Aiden Cline 2b2aacc939 fix(provider): generalize Claude adaptive thinking (#38757) 2026-07-24 16:44:00 -05:00
Aiden Cline 7840562d1b fix(llm): revert response message phases (#38761) 2026-07-24 16:31:38 -05:00
opencode-agent[bot] 53669cab2b chore: generate 2026-07-24 21:07:01 +00:00
Aiden Cline 4b19ea2a71 fix(llm): preserve response message phases (#38452) 2026-07-24 16:05:22 -05:00
151 changed files with 3564 additions and 2139 deletions
+35 -35
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -37,7 +37,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -96,7 +96,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.18.4",
"version": "1.18.7",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -144,7 +144,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -158,7 +158,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.18.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -339,7 +339,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -381,7 +381,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.18.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -449,7 +449,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"effect": "catalog:",
},
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -493,7 +493,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.18.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"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.4",
"version": "1.18.7",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -559,7 +559,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.18.4",
"version": "1.18.7",
"bin": {
"opencode": "./bin/opencode",
},
@@ -632,7 +632,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -690,7 +690,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -766,7 +766,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -781,7 +781,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -796,10 +796,10 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -841,7 +841,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -854,7 +854,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -907,7 +907,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -949,7 +949,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -976,7 +976,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1027,7 +1027,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3854,7 +3854,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.11.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-SJ6f5qa7P8md6lPrserryER3zerLkrezlnqqYQ2AbvDPpHLbwtbyk0FYJ5kNRcmbI80i/VMcsMBP0YIRdc3ucQ=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -6030,7 +6030,7 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
@@ -6050,7 +6050,7 @@
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="],
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=",
"aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=",
"aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=",
"x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ="
"x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=",
"aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=",
"aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=",
"x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus="
}
}
@@ -65,7 +65,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) {
await mockOpenCodeServer(page, {
protocol: "v2",
protocol: "v1",
directory,
project: {
id: projectID,
@@ -93,20 +93,18 @@ async function setup(page: Page) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }),
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
}),
)
await page.route("**/vcs/diff**", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory },
data:
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
}),
body: JSON.stringify(
new URL(route.request().url()).searchParams.get("mode") === "branch"
? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
),
}),
)
await page.addInitScript(
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, {
protocol: "v2",
protocol: "v1",
directory,
project: {
id: projectID,
@@ -67,33 +67,31 @@ test("keeps the review tree and terminal sized when both panels are open", async
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory },
data: { branch: "review-pane-performance", defaultBranch: "dev" },
branch: "review-pane-performance",
default_branch: "dev",
}),
}),
)
await page.route("**/api/vcs/diff**", (route) => {
await page.route("**/vcs/diff**", (route) => {
const url = new URL(route.request().url())
const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
location: { directory: scope ?? directory, project: { id: projectID, directory } },
data:
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
}),
body: JSON.stringify(
url.searchParams.get("mode") === "branch"
? detail
? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
),
})
})
await page.route("**/api/pty*", (route) =>
await page.route("**/pty*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -111,7 +109,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.route("**/api/pty/pty_review_terminal*", (route) =>
await page.route("**/pty/pty_review_terminal*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -129,7 +127,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -139,7 +137,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}),
}),
)
await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
@@ -177,8 +175,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
await lastFile.click()
@@ -192,8 +190,8 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url())
return (
url.pathname === "/api/vcs/diff" &&
url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
url.pathname === "/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
)
})
sessionStatus[sessionID] = { type: "idle" }
@@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac
const connection = new URL(connections[0]!)
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
expect(connection.searchParams.get("location[directory]")).toBe(directory)
expect(connection.searchParams.get("ticket")).toBe("e2e-ticket")
expect(connection.searchParams.get("ticket")).toBeNull()
await writeProbe(page)
await switchTab(page, titleB)
@@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/NewProject"
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
let connectedGo = false
let pendingGo = false
const connections: Array<{ integrationID: string; body: unknown }> = []
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_model_selection_flow",
worktree: directory,
vcs: "git",
name: "NewProject",
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
sandboxes: [],
},
provider: () => ({
all: [
{
id: "opencode",
name: "OpenCode",
models: {
"free-model": {
id: "free-model",
name: "Free Model",
cost: { input: 0, output: 0 },
limit: { context: 200_000 },
},
},
},
{
id: "opencode-go",
name: "OpenCode Go",
models: {
"go-model-1": {
id: "go-model-1",
name: "Go Model 1",
cost: { input: 1, output: 1 },
limit: { context: 200_000 },
},
},
},
],
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
default: { providerID: "opencode", modelID: "free-model" },
}),
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
onConnectKey: (input) => {
connections.push(input)
if (input.integrationID === "opencode-go") pendingGo = true
},
onInstanceDispose: () => {
if (pendingGo) connectedGo = true
},
sessions: [],
pageMessages: () => ({ items: [] }),
fileList: (path) =>
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
findFiles: () => ["NewProject"],
})
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
})
await page.goto("/")
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
await page.locator("[data-directory-path]").click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
const modelControl = page.locator('[data-action="prompt-model"]')
await modelControl.click()
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
await page.locator('[data-provider-id="opencode-go"]').click()
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
await page.locator('[data-action="provider-connect-submit"]').click()
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
await modelControl.click()
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
await expect(goModel).toBeVisible()
await goModel.click()
await expect(modelControl).toContainText("Go Model 1")
})
+20 -3
View File
@@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc
export interface MockServerConfig {
protocol?: "v1" | "v2"
provider: unknown
provider: unknown | (() => unknown)
integrationMethods?: Record<string, unknown[]>
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
onInstanceDispose?: () => void
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
@@ -31,7 +34,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const cursors = new Map<string, string>()
let nextCursor = 0
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
@@ -75,6 +77,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (path === "/api/health" && config.protocol === "v2")
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
if (path === "/provider")
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
if (legacyAuth && route.request().method() === "PUT") {
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
return json(route, true)
}
if (path === "/instance/dispose" && route.request().method() === "POST") {
config.onInstanceDispose?.()
return json(route, true)
}
if (path === "/permission")
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
if (path === "/question")
@@ -130,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
location: location(config),
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
})
if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST")
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
if (integrationConnect && route.request().method() === "POST") {
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
}
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.18.4",
"version": "1.18.7",
"description": "",
"type": "module",
"exports": {
@@ -53,7 +53,7 @@
"@dnd-kit/helpers": "0.5.0",
"@dnd-kit/solid": "0.5.0",
"@kobalte/core": "catalog:",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
@@ -28,7 +28,6 @@ import {
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@@ -38,7 +37,6 @@ 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"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
@@ -159,7 +157,7 @@ function ProviderPicker(props: {
const settings = useSettings()
if (settings.general.newLayoutDesigns())
return <ProviderPickerV2 directory={props.directory} onSelect={props.onSelect} onPrepare={props.onPrepare} />
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
@@ -231,7 +229,7 @@ function ProviderPickerV2(props: {
onSelect: (provider: string) => void
onPrepare?: () => void
}) {
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const language = useLanguage()
const [store, setStore] = createStore({
filter: "",
@@ -386,12 +384,11 @@ 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 providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
@@ -707,9 +704,8 @@ function ProviderConnection(props: {
})
async function complete() {
const value = directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null))
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
@@ -857,6 +853,7 @@ function ProviderConnection(props: {
ref={apiKey}
class="!w-full"
name="apiKey"
data-input="provider-api-key"
placeholder={language.t("provider.connect.apiKey.placeholder")}
value={formStore.value}
invalid={formStore.error !== undefined}
@@ -873,7 +870,7 @@ function ProviderConnection(props: {
</div>
)}
</Show>
<ButtonV2 type="submit" variant="contrast">
<ButtonV2 type="submit" variant="contrast" data-action="provider-connect-submit">
{language.t("common.continue")}
</ButtonV2>
</form>
@@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import type { Path } from "@opencode-ai/sdk/v2/client"
import {
absoluteTreePath,
activeTreeNavigation,
@@ -68,7 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
() => sdk.api.path.get().catch(() => undefined),
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
@@ -322,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
{(suggestion, index) => (
<button
id={`directory-picker-v2-suggestion-${index()}`}
data-directory-path={suggestion.absolute}
role="option"
aria-selected={index() === activeSuggestion()}
data-active={index() === activeSuggestion() ? "" : undefined}
@@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@opencode-ai/sdk/v2/client"
interface DialogSelectDirectoryProps {
title?: string
@@ -59,8 +60,12 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.api.path.get().catch(() => undefined)
async (): Promise<Path | undefined> => {
if ((await sdk.protocol) !== "v1") return
return sdk.client.path
.get()
.then((result) => result.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
@@ -159,7 +164,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
@@ -171,7 +176,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
)
}
return (
<div class="w-full flex items-center justify-between rounded-md">
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
@@ -76,7 +76,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
</DialogHeader>
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
<div ref={listEl} class="flex min-h-0 flex-col">
<div class="flex w-full flex-col items-start pb-3">
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
@@ -134,6 +134,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
{(provider) => (
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
classList={{
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
@@ -6,9 +6,7 @@ import {
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
@@ -29,6 +27,7 @@ import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
import { createEventListener } from "@solid-primitives/event-listener"
import { matchesModelSearch } from "./dialog-select-model-search"
@@ -122,14 +121,13 @@ const ModelList: Component<{
}
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: (cause: "escape" | "select") => void
}) {
const [store, setStore] = createStore<{
@@ -174,9 +172,7 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={4}
>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Trigger as={props.trigger} />
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
@@ -237,66 +233,101 @@ export function ModelSelectorPopover(props: {
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
trigger: ModelSelectorTrigger
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
return (
<ModelSelectorPopoverV2View
trigger={props.trigger}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
onManage={() => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
}}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
)
const models = createMemo(() => {
const search = store.search.trim()
const filtered = search
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
})
const groups = createMemo(() => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
})
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
return {
models: (search: string) => {
const query = search.trim()
const filtered = query
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
},
groups: (models: ModelItem[]) => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
},
current: () => {
const value = model.current()
return value ? modelKey(value) : undefined
},
select: (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
input.onSelect()
},
}
}
function ModelSelectorPopoverV2View(props: {
trigger: ModelSelectorTrigger
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
onManage: () => void
onClose: () => void
}) {
const language = useLanguage()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
const models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const initialActive = () => {
const selected = current()
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
}
const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => {
if (open) {
restoreTrigger = true
dismiss.allowTriggerRestore()
setStore({ open: true, active: initialActive() })
setTimeout(() =>
requestAnimationFrame(() => {
@@ -308,23 +339,15 @@ export function ModelSelectorPopoverV2(props: {
}
setStore({ open: false, search: "", active: "" })
}
const select = (item: ModelItem) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
props.onClose?.()
}
const selectModel = (item: ModelItem) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => select(item))
dismiss.afterClose(() => props.select(item))
}
const manage = () => {
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
dismiss.afterClose(props.onManage)
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +366,7 @@ export function ModelSelectorPopoverV2(props: {
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
const first = props.models(value)[0]
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
@@ -362,18 +382,14 @@ export function ModelSelectorPopoverV2(props: {
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Trigger as={props.trigger} />
<MenuV2.Portal>
<MenuV2.Content
ref={(el: HTMLDivElement) => (contentRef = el)}
ref={(element: HTMLDivElement) => (contentRef = element)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -393,9 +409,9 @@ export function ModelSelectorPopoverV2(props: {
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
restoreTrigger = false
dismiss.preventTriggerRestore()
setOpen(false)
afterClose(() => props.onClose?.())
dismiss.afterClose(props.onClose)
return
}
if (event.altKey || event.metaKey) return
@@ -445,7 +461,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.GroupLabel class="gap-2 px-3">
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={current()}>
<MenuV2.RadioGroup value={props.current()}>
<For each={group.items}>
{(item) => (
<TooltipV2
@@ -465,7 +481,7 @@ export function ModelSelectorPopoverV2(props: {
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
data-selected-model={props.current() === modelKey(item) ? true : undefined}
class="scroll-my-6 w-full"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
@@ -6,22 +6,17 @@ 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 { 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 { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
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 { ServerConnection } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
const DEFAULT_USERNAME = "opencode"
import {
type ServerDomainController,
type ServerFormController,
useServerDomainController,
useServerFormController,
} from "@/components/server/server-management-controller"
interface ServerFormProps {
value: string
@@ -40,76 +35,6 @@ 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) => {
@@ -177,400 +102,40 @@ function ServerForm(props: ServerFormProps) {
export function DialogSelectServer() {
const dialog = useDialog()
const controller = useServerManagementController({ onSelect: dialog.close })
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>
)
}
return (
<Dialog title={controller.formTitle()}>
<Dialog title={title()}>
<div class="flex flex-1 min-h-0 flex-col px-5">
<Show when={controller.isFormMode()} fallback={<ServerConnectionList controller={controller} />}>
<ServerConnectionForm controller={controller} />
<Show
when={form.state.open()}
fallback={<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />}
>
<ServerConnectionForm form={form} />
</Show>
</div>
</Dialog>
)
}
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> }) {
export function ServerConnectionList(props: {
domain: ServerDomainController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
const language = useLanguage()
const settings = useSettings()
@@ -584,10 +149,10 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={props.controller.sortedItems}
items={props.domain.collection.items}
key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.controller.select(x)
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}}
divider={true}
>
@@ -596,15 +161,15 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
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.controller.status()[key]} />
<ServerHealthIndicator health={props.domain.collection.health()[key]} />
</div>
<ServerRow
conn={i}
dimmed={props.controller.status()[key]?.healthy === false}
status={props.controller.status()[key]}
dimmed={props.domain.collection.health()[key]?.healthy === false}
status={props.domain.collection.health()[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={props.controller.defaultKey() === ServerConnection.key(i)}>
<Show when={props.domain.defaults.key() === 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>
@@ -613,7 +178,12 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={props.controller.current() && ServerConnection.key(props.controller.current()!) === key}>
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" />
</Show>
@@ -632,18 +202,18 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
props.controller.startEdit(i)
props.onEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(key)}>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(key)}>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.default")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<DropdownMenu.Item onSelect={() => props.controller.setDefault(null)}>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<DropdownMenu.Item onSelect={() => props.domain.defaults.set(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
@@ -651,7 +221,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => props.controller.handleRemove(ServerConnection.key(i))}
onSelect={() => props.domain.connection.remove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
@@ -671,7 +241,7 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
variant="secondary"
icon="plus-small"
size="large"
onClick={props.controller.startAdd}
onClick={props.onAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
@@ -681,38 +251,38 @@ export function ServerConnectionList(props: { controller: ReturnType<typeof useS
)
}
export function ServerConnectionForm(props: { controller: ReturnType<typeof useServerManagementController> }) {
export function ServerConnectionForm(props: { form: ServerFormController }) {
const language = useLanguage()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<ServerForm
value={props.controller.formValue()}
name={props.controller.formName()}
username={props.controller.formUsername()}
password={props.controller.formPassword()}
value={props.form.state.value()}
name={props.form.state.name()}
username={props.form.state.username()}
password={props.form.state.password()}
placeholder={language.t("dialog.server.add.placeholder")}
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}
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}
/>
<div class="shrink-0 pb-5">
<Button
variant="primary"
size="large"
onClick={props.controller.submitForm}
disabled={props.controller.formBusy()}
onClick={props.form.submit}
disabled={props.form.state.busy()}
class="px-3 py-1.5"
>
{props.controller.formBusy()
{props.form.state.busy()
? language.t("dialog.server.add.checking")
: props.controller.isAddMode()
: props.form.state.adding()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
+17 -6
View File
@@ -71,12 +71,23 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const project = await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
if ((await serverCtx().sdk.protocol) !== "v1") return
const project = await serverCtx()
.sdk.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
.then((result) => result.data)
if (!project) return
// const project = await serverCtx().sdk.api.project.update({
// projectID: props.project.id,
// name,
// icon: { color: store.color || "", override: store.iconOverride || "" },
// commands: { start },
// })
serverCtx().sync.set("project", (items) =>
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
)
+74 -83
View File
@@ -5,7 +5,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import type { Prompt, ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, on, Show } from "solid-js"
import { ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
@@ -37,11 +37,9 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
edit?: PromptInputProps["edit"]
onEditLoaded?: PromptInputProps["onEditLoaded"]
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "edit" | "onEditLoaded" | "submission">
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
export type PromptInputV2ComposerController = PromptInputV2Interaction & {
readonly model: PromptInputProps["controls"]["model"]
}
@@ -51,9 +49,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
const command = useCommand()
const language = useLanguage()
useCommands(props)
useEditHandler(props)
return (
<div class="flex flex-col gap-3">
<PromptInputV2
@@ -82,70 +77,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
)
}
const useEditHandler = (props: PromptInputV2ComposerProps) => {
const prompt = usePrompt()
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
props.controller.dispatch({ type: "mode.normal" })
props.controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
props.controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
}
const useCommands = (props: PromptInputV2ComposerProps) => {
const command = useCommand()
const language = useLanguage()
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: props.controller.state.mode !== "normal",
onSelect: () => props.controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: props.controller.state.mode === "shell",
onSelect: () => props.controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: props.controller.state.mode === "normal",
onSelect: () => props.controller.dispatch({ type: "mode.normal" }),
},
])
}
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
const sdk = useSDK()
const sync = useSync()
@@ -472,6 +403,62 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
},
})
Object.defineProperty(controller, "model", { get: () => props.controls.model })
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: controller.state.mode !== "normal",
onSelect: () => controller.attach(),
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: "mod+shift+x",
disabled: controller.state.mode === "shell",
onSelect: () => controller.dispatch({ type: "mode.shell" }),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: "mod+shift+e",
disabled: controller.state.mode === "normal",
onSelect: () => controller.dispatch({ type: "mode.normal" }),
},
])
createEffect(
on(
() => props.edit?.id,
(id) => {
const edit = props.edit
if (!id || !edit) return
prompt.context.items().forEach((item) => prompt.context.remove(item.key))
edit.context.forEach((item) =>
prompt.context.add({
type: item.type,
path: item.path,
selection: item.selection,
comment: item.comment,
commentID: item.commentID,
commentOrigin: item.commentOrigin,
preview: item.preview,
}),
)
controller.dispatch({ type: "mode.normal" })
controller.resetHistory()
prompt.set(edit.prompt, promptLength(edit.prompt))
controller.restoreFocus()
props.onEditLoaded?.()
},
{ defer: true },
),
)
return controller as PromptInputV2ComposerController
}
@@ -521,6 +508,7 @@ function PromptInputV2ModelControl(props: {
fallback={
<ButtonV2
data-action="prompt-model"
data-control-type="dialog"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
@@ -534,19 +522,22 @@ function PromptInputV2ModelControl(props: {
>
<ModelSelectorPopoverV2
model={props.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": shouldAnimate() },
"data-action": "prompt-model",
}}
trigger={(triggerProps) => (
<ButtonV2
{...triggerProps}
variant="ghost-muted"
size="normal"
style={{ height: "28px" }}
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": shouldAnimate() }}
data-action="prompt-model"
data-control-type="popover"
>
{content()}
</ButtonV2>
)}
onClose={props.onClose}
>
{content()}
</ModelSelectorPopoverV2>
/>
</Show>
</TooltipV2>
</Show>
+25 -23
View File
@@ -709,7 +709,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name,
description: cmd.description,
type: "custom" as const,
source: cmd.source,
// source: cmd.source,
}))
return [...custom, ...builtin]
@@ -1723,29 +1723,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<ModelSelectorPopover
model={props.controls.model.selection}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
trigger={(triggerProps) => (
<Button
{...triggerProps}
variant="ghost"
size="normal"
style={control()}
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
data-action="prompt-model"
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
)}
onClose={restoreFocus}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
<ProviderIcon
id={props.controls.model.selection.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{props.controls.model.selection.current()?.name ??
language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
/>
</TooltipKeybind>
</Show>
</div>
@@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { pathKey } from "@/utils/path-key"
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
export type PromptProject = {
name?: string
@@ -197,8 +198,8 @@ export function PromptProjectSelector(props: {
}) {
const [triggerReady, setTriggerReady] = createSignal(false)
let contentRef: HTMLDivElement | undefined
const dismiss = createMenuDismissController(() => contentRef)
let triggerFrame: number | undefined
let restoreTrigger = true
// Floating UI requires a connected anchor; route transitions can construct this trigger before adoption.
const setTriggerRef = (element: HTMLButtonElement) => {
@@ -221,25 +222,15 @@ export function PromptProjectSelector(props: {
props.controller.active()
? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(props.controller.active())}"]`)
: undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const selectProject = (project: PromptProject) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.select(project))
dismiss.afterClose(() => props.controller.select(project))
}
const selectAction = (server?: string) => {
restoreTrigger = false
dismiss.preventTriggerRestore()
props.controller.setOpen(false)
afterClose(() => props.controller.add(server))
dismiss.afterClose(() => props.controller.add(server))
}
const selectActive = () => {
const project = props.controller.activeProject()
@@ -267,7 +258,7 @@ export function PromptProjectSelector(props: {
)
.filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap"))
.findLast((element) => element.offsetParent !== null)
restoreTrigger = false
dismiss.preventTriggerRestore()
target?.focus()
queueMicrotask(() => {
if (props.controller.open()) props.controller.setOpen(false)
@@ -291,7 +282,10 @@ export function PromptProjectSelector(props: {
placement={props.placement ?? "bottom"}
gutter={4}
modal={false}
onOpenChange={(open) => props.controller.setOpen(open)}
onOpenChange={(open) => {
if (open) dismiss.allowTriggerRestore()
props.controller.setOpen(open)
}}
>
<DropdownMenu.Trigger as={ProjectTrigger} ref={setTriggerRef} controller={props.controller} />
<DropdownMenu.Portal>
@@ -300,11 +294,9 @@ export function PromptProjectSelector(props: {
id="prompt-project-menu"
class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none"
onOpenAutoFocus={(event) => event.preventDefault()}
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
onPointerDownOutside={dismiss.preventTriggerRestore}
onFocusOutside={dismiss.preventTriggerRestore}
onCloseAutoFocus={dismiss.onCloseAutoFocus}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
@@ -364,41 +356,45 @@ export function PromptProjectSelector(props: {
</button>
</Show>
</div>
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
<div class="max-h-[224px] overflow-y-auto">
<Show
when={props.controller.servers().length > 1}
fallback={
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects()}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
<For
each={props.controller
.servers()
.filter((server) =>
props.controller.projects().some((project) => project.server?.key === server!.key),
)}
>
{(server) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{server!.name}
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For
each={props.controller.projects().filter((project) => project.server?.key === server!.key)}
>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
<DropdownMenu.RadioGroup value={selectedValue()}>
<For each={props.controller.projects().filter((project) => project.server?.key === server!.key)}>
{(project) => (
<ProjectItem project={project} controller={props.controller} onSelect={selectProject} />
)}
</For>
</DropdownMenu.RadioGroup>
</div>
)}
</For>
</Show>
)}
</For>
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
@@ -0,0 +1,336 @@
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>
@@ -0,0 +1,99 @@
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([])
})
})
@@ -0,0 +1,60 @@
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 { useServerManagementController } from "@/components/dialog-select-server"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController>
domain: ServerActionsController
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.controller.canDefault()}
isDefault={props.controller.defaultKey() === key}
canDefault={props.domain.defaults.available()}
isDefault={props.domain.defaults.key() === key}
onEdit={props.onEdit}
onSetDefault={() => props.controller.setDefault(key)}
onRemoveDefault={() => props.controller.setDefault(null)}
onRemove={() => props.controller.handleRemove(key)}
onSetDefault={() => props.domain.defaults.set(key)}
onRemoveDefault={() => props.domain.defaults.set(null)}
onRemove={() => props.domain.connection.remove(key)}
open={props.open}
onOpenChange={props.onOpenChange}
/>
@@ -132,7 +132,8 @@ export const SettingsGeneral: Component = () => {
if ((await sdk.protocol) === "v1") {
return (await sdk.client.pty.shells()).data ?? []
}
return (await sdk.api.pty.shells()).data
// return (await sdk.api.pty.shells()).data
return [] as ShellOption[]
},
{ initialValue: [] as ShellOption[] },
)
@@ -0,0 +1,103 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createKeybindSettingsController } from "./settings-keybinds"
function setup(overrides: Record<string, string> = {}) {
const changes: [string, string][] = []
const suppression: boolean[] = []
const notifications: { title: string; description?: string }[] = []
let resets = 0
let controller: ReturnType<typeof createKeybindSettingsController>
const dispose = createRoot((dispose) => {
controller = createKeybindSettingsController({
command: {
catalog: [
{ id: "session.alpha", title: "Alpha", keybind: "mod+a" },
{ id: "session.beta", title: "Beta", keybind: "mod+b" },
],
options: [],
keybinds: (enabled) => suppression.push(enabled),
},
locale: () => "en",
translate: (key, params) => {
if (params) return `${key}:${Object.values(params).join("|")}`
if (key === "common.key.alt") return "Alt"
return String(key)
},
settings: {
current: { keybinds: overrides },
keybinds: {
get: (id) => overrides[id],
set: (id, value) => {
overrides[id] = value
changes.push([id, value])
},
resetAll: () => {
resets++
},
},
},
notify: (toast) => notifications.push(toast),
})
return dispose
})
return {
controller: controller!,
changes,
suppression,
notifications,
resets: () => resets,
dispose,
}
}
function modKey(key: string) {
const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true })
}
describe("keybind settings controller", () => {
test("derives the catalog, effective bindings, and filtered groups", () => {
const state = setup({ "session.beta": "alt+k" })
expect(state.controller.catalog.title("session.alpha")).toBe("Alpha")
expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K")
expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"])
expect(state.controller.settings.hasOverrides()).toBe(true)
state.dispose()
})
test("captures bindings, rejects conflicts, and restores command handling", () => {
const state = setup()
state.controller.capture.toggle("session.beta")
document.dispatchEvent(modKey("a"))
expect(state.changes).toEqual([])
expect(state.notifications).toHaveLength(1)
expect(state.controller.capture.active()).toBe("session.beta")
document.dispatchEvent(modKey("x"))
expect(state.changes).toEqual([["session.beta", "mod+x"]])
expect(state.suppression).toEqual([false, true])
expect(state.controller.capture.active()).toBeNull()
state.controller.capture.toggle("session.alpha")
state.dispose()
expect(state.suppression).toEqual([false, true, false, true])
document.dispatchEvent(modKey("z"))
expect(state.changes).toEqual([["session.beta", "mod+x"]])
})
test("resets persisted overrides and reports success", () => {
const state = setup({ "session.alpha": "none" })
state.controller.settings.reset()
expect(state.resets()).toBe(1)
expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title")
state.dispose()
})
})
+299 -61
View File
@@ -30,6 +30,8 @@ type KeybindMeta = {
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
type LanguageContext = ReturnType<typeof useLanguage>
type SettingsContext = ReturnType<typeof useSettings>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
@@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap {
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
function listFor(command: Pick<CommandContext, "catalog" | "options">, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" })
@@ -262,11 +264,288 @@ function useKeyCapture(input: {
})
}
export function createKeybindSettingsController(input: {
command: Pick<CommandContext, "catalog" | "options" | "keybinds">
locale: LanguageContext["locale"]
translate: LanguageContext["t"]
settings: {
current: { keybinds: unknown }
keybinds: Pick<SettingsContext["keybinds"], "get" | "set" | "resetAll">
}
target?: Document
notify?: (toast: { title: string; description: string }) => void
}) {
const [store, setStore] = createStore({ active: null as string | null })
const overrides = createMemo(() => keybinds(input.settings.current.keybinds))
const list = createMemo(() => {
input.locale()
return listFor(input.command, overrides(), input.translate("command.palette"))
})
const grouped = createMemo(() => groupedFor(list()))
const title = (id: string) => list().get(id)?.title ?? ""
const effective = (id: string) => {
if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND
const custom = input.settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = input.command.options.find((item) => item.id === id)
if (live?.keybind) return live.keybind
return input.command.catalog.find((item) => item.id === id)?.keybind
}
const used = createMemo(() => {
const value = new Map<string, { id: string; title: string }[]>()
for (const id of list().keys()) {
for (const signature of signatures(effective(id))) {
const items = value.get(signature)
if (items) {
items.push({ id, title: title(id) })
continue
}
value.set(signature, [{ id, title: title(id) }])
}
}
return value
})
const stop = () => {
if (!store.active) return
setStore("active", null)
input.command.keybinds(true)
}
const toggle = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
input.command.keybinds(false)
}
const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast))
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.settings.keybinds.set(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const signature of signatures(next)) {
for (const item of used().get(signature) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
notify({
title: input.translate("settings.shortcuts.conflict.title"),
description: input.translate("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next, input.translate),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.settings.keybinds.set(id, next)
stop()
}
makeEventListener(input.target ?? document, "keydown", handle, { capture: true })
})
onCleanup(() => {
if (store.active) input.command.keybinds(true)
})
return {
catalog: {
groups: GROUPS,
filtered: (query: string) =>
filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", input.translate)),
title,
keybind: (id: string) => formatKeybind(effective(id) ?? "", input.translate),
},
capture: {
active: () => store.active,
toggle,
},
settings: {
hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"),
reset: () => {
stop()
input.settings.keybinds.resetAll()
notify({
title: input.translate("settings.shortcuts.reset.toast.title"),
description: input.translate("settings.shortcuts.reset.toast.description"),
})
},
},
}
}
function SettingsKeybindsV2(props: { command: CommandContext; language: LanguageContext; settings: SettingsContext }) {
const controller = createKeybindSettingsController({
command: props.command,
locale: props.language.locale,
translate: props.language.t,
settings: props.settings,
})
return (
<SettingsKeybindsV2View
groups={controller.catalog.groups}
filtered={controller.catalog.filtered}
title={controller.catalog.title}
keybind={controller.catalog.keybind}
active={controller.capture.active}
onCapture={controller.capture.toggle}
hasOverrides={controller.settings.hasOverrides}
onReset={controller.settings.reset}
groupLabel={(group) => props.language.t(groupKey[group])}
titleLabel={() => props.language.t("settings.shortcuts.title")}
resetLabel={() => props.language.t("settings.shortcuts.reset.button")}
searchLabel={() => props.language.t("settings.shortcuts.search.placeholder")}
emptyLabel={() => props.language.t("settings.shortcuts.search.empty")}
unassignedLabel={() => props.language.t("settings.shortcuts.unassigned")}
pressKeysLabel={() => props.language.t("settings.shortcuts.pressKeys")}
/>
)
}
function SettingsKeybindsV2View(props: {
groups: KeybindGroup[]
filtered: (query: string) => Map<KeybindGroup, string[]>
title: (id: string) => string
keybind: (id: string) => string
active: () => string | null
onCapture: (id: string) => void
hasOverrides: () => boolean
onReset: () => void
groupLabel: (group: KeybindGroup) => string
titleLabel: () => string
resetLabel: () => string
searchLabel: () => string
emptyLabel: () => string
unassignedLabel: () => string
pressKeysLabel: () => string
}) {
const [store, setStore] = createStore({ filter: "" })
const filtered = createMemo(() => props.filtered(store.filter))
const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0))
return (
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{props.titleLabel()}</h2>
<ButtonV2 variant="ghost" onClick={props.onReset} disabled={!props.hasOverrides()}>
{props.resetLabel()}
</ButtonV2>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={props.searchLabel()}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={props.searchLabel()}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-shortcuts flex flex-col gap-8">
<For each={props.groups}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div class="settings-v2-section">
<h3 class="settings-v2-section-title">{props.groupLabel(group)}</h3>
<SettingsListV2>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span>{props.title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": true,
"settings-v2-keybind-button--active": props.active() === id,
}}
onClick={() => props.onCapture(id)}
>
<Show when={props.active() === id} fallback={props.keybind(id) || props.unassignedLabel()}>
{props.pressKeysLabel()}
</Show>
</button>
</div>
)}
</For>
</SettingsListV2>
</div>
</Show>
)}
</For>
<Show when={store.filter && !hasResults()}>
<div class="settings-v2-shortcuts-status">
<span>{props.emptyLabel()}</span>
<span class="settings-v2-shortcuts-status-filter">&quot;{store.filter}&quot;</span>
</div>
</Show>
</div>
</div>
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
if (props.v2) return <SettingsKeybindsV2 command={command} language={language} settings={settings} />
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
@@ -476,78 +755,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
)
return (
<Show
when={props.v2}
fallback={
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
}
>
<>
<div class="settings-v2-tab-header settings-v2-tab-header--stacked">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.shortcuts.title")}</h2>
<ButtonV2 variant="ghost" onClick={resetAll} disabled={!hasOverrides()}>
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</ButtonV2>
</Button>
</div>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("settings.shortcuts.search.placeholder")}
class="flex-1"
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
<div class="settings-v2-tab-body">{groups}</div>
</>
</Show>
</div>
{groups}
</div>
)
}
@@ -41,7 +41,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
const serverSDK = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providers = useProviders(() => undefined)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
@@ -1,16 +1,19 @@
import { Show, type Component } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useLanguage } from "@/context/language"
import { ServerConnectionForm, ServerConnectionList, useServerManagementController } from "./dialog-select-server"
import { useServerDomainController, useServerFormController } from "./server/server-management-controller"
import { ServerConnectionForm, ServerConnectionList } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
const controller = useServerManagementController()
const domain = useServerDomainController()
const form = useServerFormController()
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={controller.isFormMode()}
when={form.state.open()}
fallback={
<>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -18,13 +21,25 @@ export const SettingsServers: Component = () => {
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<ServerConnectionList controller={controller} />
<ServerConnectionList domain={domain} onAdd={form.start.add} onEdit={form.start.edit} />
</>
}
>
<div class="flex flex-1 min-h-0 flex-col gap-4 pt-6">
<div class="text-16-medium text-text-strong">{controller.formTitle()}</div>
<ServerConnectionForm controller={controller} />
<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>
</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 { useServerManagementController } from "../dialog-select-server"
import { useServerFormController } from "../server/server-management-controller"
import "./settings-v2.css"
export const DialogServerV2: Component<{
@@ -15,39 +15,39 @@ export const DialogServerV2: Component<{
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController({
const form = useServerFormController({
onSelect: () => dialog.close(),
navigateOnAdd: false,
})
const [opened, setOpened] = createSignal(false)
onMount(() => {
if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
if (props.mode === "add") form.start.add()
if (props.mode === "edit" && props.server) form.start.edit(props.server)
setOpened(true)
})
onCleanup(() => {
controller.resetForm()
form.reset()
})
createEffect(() => {
if (!opened()) return
if (controller.isFormMode()) return
if (form.state.open()) return
dialog.close()
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
controller.submitForm()
form.submit()
}
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (form.state.busy()) 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={controller.formValue()}
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()}
disabled={controller.formBusy()}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
<Show when={form.state.error()}>
<span class="settings-v2-server-dialog-error">{form.state.error()}</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={controller.formName()}
value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -98,10 +98,10 @@ export const DialogServerV2: Component<{
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formUsername()}
value={form.state.username()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
disabled={form.state.busy()}
onInput={(event) => form.change.username(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -111,10 +111,10 @@ export const DialogServerV2: Component<{
type="password"
appearance="large"
class="!w-full self-stretch"
value={controller.formPassword()}
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
@@ -122,10 +122,10 @@ export const DialogServerV2: Component<{
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
<ButtonV2 variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
<ButtonV2 variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</ButtonV2>
</DialogFooter>
@@ -1,4 +1,4 @@
import { Component, createSignal, startTransition } from "solid-js"
import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
@@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
export const DialogSettings: Component<{
sessionID?: string
@@ -19,7 +22,20 @@ export const DialogSettings: Component<{
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
const showProviders = () => {
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
@@ -87,7 +103,7 @@ export const DialogSettings: Component<{
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 onBack={showProviders} />
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
@@ -0,0 +1,73 @@
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 }
}
@@ -0,0 +1,55 @@
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()
}
})
})
@@ -0,0 +1,198 @@
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,181 +1,346 @@
import { Component, Show, createMemo, createResource, onMount } from "solid-js"
import { Component, Show, createMemo, createResource, type Accessor } 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 {
monoDefault,
monoFontFamily,
monoInput,
sansDefault,
sansFontFamily,
sansInput,
terminalDefault,
terminalFontFamily,
terminalInput,
useSettings,
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { useSettings } from "@/context/settings"
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"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
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)}
/>
)
}
type ThemeOption = {
id: string
name: string
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 ShellOption = {
path: string
name: string
acceptable: boolean
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 ShellSelectOption = {
id: string
value: string
label: string
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
}
// 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 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)}
/>
)
}
const playDemoSound = (id: string | undefined) => {
stopDemoSound()
if (!id) return
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 run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => {
void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100)
}
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>
)
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 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 permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
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
},
{ initialValue: [] as ShellOption[] },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => desktop() && "getPinchZoomEnabled" in platform,
() => 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)
@@ -183,12 +348,6 @@ 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,
@@ -196,39 +355,6 @@ 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")}
@@ -250,7 +376,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()}
/>
)
@@ -274,35 +400,9 @@ export const SettingsGeneralV2: Component<{
/>
</SettingsRowV2>
<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>
<PermissionScopeSetting controller={permissionScope} />
<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>
<ShellSetting controller={shell} />
<SettingsRowV2
title={language.t("settings.general.row.reasoningSummaries.title")}
@@ -413,124 +513,6 @@ 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>
@@ -575,68 +557,6 @@ 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>
@@ -658,7 +578,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>
@@ -703,11 +623,11 @@ export const SettingsGeneralV2: Component<{
<GeneralSection />
<AppearanceSection />
<AppearanceSection controller={appearance} />
<NotificationsSection />
<SoundsSection />
<SoundsSection controller={sounds} />
<Show when={desktop()}>
<UpdatesSection />
@@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
@@ -29,18 +29,21 @@ const PROVIDER_NOTES = [
const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
export const SettingsProvidersV2: Component<{
directory: Accessor<string | undefined>
onBack?: () => void
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync()
const providers = useProviders()
const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => {
providerConnect.select(provider)
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
}
const connected = createMemo(() => {
@@ -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 { useServerManagementController } from "../dialog-select-server"
import { useServerCollectionController } from "../server/server-management-controller"
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 controller = useServerManagementController()
const domain = useServerCollectionController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
() => domain.collection.items().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
)
const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item))
const items = domain.collection.items().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 = () => {
dialog.push(() => <DialogServerV2 mode="add" />)
void dialog.push(() => <DialogServerV2 mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
void dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
}
return (
@@ -97,12 +97,12 @@ export const SettingsServersV2: Component = () => {
}
>
<SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} />
<WslServerSettings domain={domain} servers={wslServers} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.status()[key]
const isDefault = () => controller.defaultKey() === key
const health = () => domain.collection.health()[key]
const isDefault = () => domain.defaults.key() === 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={controller.canDefault() && isDefault()}>
<Show when={domain.defaults.available() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
<ServerRowMenu server={item} domain={domain} onEdit={openEdit} />
</div>
</div>
)
@@ -1,11 +1,21 @@
import { describe, expect, test } from "bun:test"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
describe("serverStatusDotClass", () => {
test("uses the success token while the server and services are healthy", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: false })).toBe("bg-icon-success-base")
})
test("uses the session attention token when a service needs attention", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, attention: true, issue: true })).toBe(
"bg-v2-background-bg-accent",
)
})
test("uses the warning token for non-blocking issues while the server is online", () => {
expect(serverStatusDotClass({ ready: true, serverHealth: true, issue: true })).toBe("bg-icon-warning-base")
})
@@ -34,3 +44,15 @@ describe("hasNonBlockingServiceIssue", () => {
expect(hasNonBlockingServiceIssue({ mcp: [], lsp: ["connected"] })).toBe(false)
})
})
describe("hasServiceNeedingAttention", () => {
test("detects MCP states that need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["needs_auth"] })).toBe(true)
expect(hasServiceNeedingAttention({ mcp: ["needs_client_registration"] })).toBe(true)
})
test("ignores states that do not need user attention", () => {
expect(hasServiceNeedingAttention({ mcp: ["failed"] })).toBe(false)
expect(hasServiceNeedingAttention({ mcp: ["connected", "pending", "disabled"] })).toBe(false)
})
})
@@ -1,6 +1,10 @@
import type { LspStatus } from "@opencode-ai/sdk/v2/client"
import type { McpServer } from "@opencode-ai/client/promise"
export function hasServiceNeedingAttention(input: { mcp: Array<McpServer["status"]["status"]> }) {
return input.mcp.some((status) => status === "needs_auth" || status === "needs_client_registration")
}
export function hasNonBlockingServiceIssue(input: {
mcp: Array<McpServer["status"]["status"]>
lsp: Array<LspStatus["status"]>
@@ -11,9 +15,15 @@ export function hasNonBlockingServiceIssue(input: {
)
}
export function serverStatusDotClass(input: { ready: boolean; serverHealth: boolean | undefined; issue: boolean }) {
export function serverStatusDotClass(input: {
ready: boolean
serverHealth: boolean | undefined
attention?: boolean
issue: boolean
}) {
if (input.serverHealth === false) return "bg-icon-critical-base"
if (!input.ready || input.serverHealth === undefined) return "bg-border-weak-base"
if (input.attention) return "bg-v2-background-bg-accent"
if (input.issue) return "bg-icon-warning-base"
if (input.serverHealth === true) return "bg-icon-success-base"
return "bg-border-weak-base"
+19 -1
View File
@@ -9,7 +9,11 @@ import { ServerConnection, useServer } from "@/context/server"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
import { hasNonBlockingServiceIssue, serverStatusDotClass } from "./status-popover-indicator"
import {
hasNonBlockingServiceIssue,
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
@@ -22,6 +26,11 @@ export function StatusPopover() {
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -49,6 +58,7 @@ export function StatusPopover() {
class={`absolute -top-px -right-px size-1.5 rounded-full ${serverStatusDotClass({
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
})}`}
/>
@@ -85,6 +95,11 @@ function DirectoryStatusPopover() {
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
}),
)
const issue = createMemo(() =>
hasNonBlockingServiceIssue({
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
@@ -95,6 +110,7 @@ function DirectoryStatusPopover() {
shown: shown(),
ready: ready(),
serverHealth: serverHealth(),
attention: attention(),
issue: issue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
@@ -118,6 +134,7 @@ function ServerStatusPopover() {
shown: shown(),
ready: serverHealth() !== undefined,
serverHealth: serverHealth(),
attention: false,
issue: false,
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
@@ -135,6 +152,7 @@ type StatusPopoverState = {
shown: boolean
ready: boolean
serverHealth: boolean | undefined
attention: boolean
issue: boolean
label: string
onOpenChange: (value: boolean) => void
+8 -14
View File
@@ -573,19 +573,13 @@ export const Terminal = (props: TerminalProps) => {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
return sdk()
.api.pty.connectToken({
ptyID: id,
location: { directory },
"x-opencode-ticket": "1",
})
.then((result) => result.data.ticket)
.catch((err: unknown) => {
if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") {
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
}
throw err
})
// return sdk()
// .api.pty.connectToken({
// ptyID: id,
// location: { directory },
// "x-opencode-ticket": "1",
// })
// .then((result) => result.data.ticket)
}
const retry = (err: unknown) => {
@@ -616,7 +610,7 @@ export const Terminal = (props: TerminalProps) => {
return undefined
})
const protocol = await sdk().protocol
if (protocol === "v2" && !ticket) return
// if (protocol === "v2" && !ticket) return
if (once.value) return
if (disposed) return
@@ -12,6 +12,6 @@ export function forwardTabRef(ref: Ref<HTMLDivElement> | undefined, element: HTM
if (typeof ref === "function") ref(element)
}
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) {
return !dragging && !editing && !committing
export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, pending: boolean) {
return !dragging && !editing && !pending
}
@@ -1,14 +1,13 @@
import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { showToast } from "@/utils/toast"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
@@ -23,8 +22,7 @@ export function TabNavItem(props: {
server: ServerConnection.Key
session: () => Session | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onRename: (title: string) => Promise<void>
onClose: () => void
onNavigate: () => void
active?: boolean
@@ -34,13 +32,12 @@ export function TabNavItem(props: {
pressed?: boolean
hidden?: boolean
}) {
const language = useLanguage()
const [editing, setEditing] = createSignal(false)
const [titleOverflowing, setTitleOverflowing] = createSignal(false)
let tabRoot!: HTMLDivElement
let titleEl!: HTMLSpanElement
let committing = false
let measureFrame: number | undefined
const rename = createMutation(() => ({ mutationFn: props.onRename }))
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@@ -116,40 +113,20 @@ export function TabNavItem(props: {
selection?.addRange(range)
}
const rename = async (title: string) => {
const ctx = serverCtx()
const session = props.session()
if (!ctx || !session) return
await ctx.sdk.api.session.rename({ sessionID: session.id, title })
}
const closeRename = async (save: boolean) => {
if (committing || !editing()) return
committing = true
if (rename.isPending || !editing()) return
const original = props.session()?.title ?? ""
const next = (titleEl.textContent ?? "").trim()
titleEl.scrollLeft = 0
if (save && next && next !== original) props.onTitleChange?.(next)
setEditing(false)
if (!save || !next || next === original) {
committing = false
return
}
try {
await rename(next)
} catch (err) {
props.onTitleChangeFailed?.(original)
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
committing = false
await rename.mutateAsync(next)
}
createEffect(() => {
@@ -163,7 +140,7 @@ export function TabNavItem(props: {
const openRename = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canOpenTabRename(props.dragging, editing(), committing)) return
if (!canOpenTabRename(props.dragging, editing(), rename.isPending)) return
const session = props.session()
if (!session) return
titleEl.textContent = session.title
@@ -15,6 +15,7 @@ import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { showToast } from "@/utils/toast"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
function SessionTabSlot(props: {
@@ -52,6 +53,25 @@ function SessionTabSlot(props: {
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
let prefetched = false
const rename = async (title: string) => {
const value = session()
const ctx = props.serverCtx()
if (!value || !ctx) return
ctx.sync.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx()
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
showToast({
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : undefined,
})
}
}
createEffect(() => {
const ctx = props.serverCtx()
const value = session()
@@ -99,16 +119,7 @@ function SessionTabSlot(props: {
server={props.tab.server}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onTitleChangeFailed={(title) => {
const value = session()
const ctx = props.serverCtx()
if (value && ctx) ctx.sync.session.remember({ ...value, title })
}}
onRename={rename}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
+10 -92
View File
@@ -5,7 +5,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -30,31 +29,11 @@ import "./titlebar.css"
import { newTabTooltipKeybind } from "./command-tooltip-keybind"
import { normalizeSessionInfo } from "@/utils/session"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40
const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const macTrafficLightsBaseWidth = 84
export type TitlebarUpdate = {
version: () => string | undefined
@@ -74,7 +53,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const server = useServer()
const navigate = useNavigate()
const location = useLocation()
@@ -85,9 +63,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
@@ -176,56 +154,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
},
])
const getWin = () => {
if (platform.platform !== "desktop") return
return currentDesktopWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const win = currentThemeWindow()
if (!win?.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
const maximize = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (interactive(e.target)) return
if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
const win = getWin()
if (!win?.toggleMaximize) return
e.preventDefault()
void win.toggleMaximize().catch(() => undefined)
}
return (
<header
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
@@ -238,16 +166,12 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
style={{
"min-height": minHeight(),
// Keep native macOS traffic lights clear even when the desktop window is narrow.
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
"padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0,
width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"align-self": windows() ? "flex-start" : undefined,
}}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
<Switch>
<Match when={useV2Titlebar()}>
@@ -459,8 +383,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
classList={{
"pt-2": !bottom(),
"pb-2": bottom(),
"md:pl-2": mac(),
"md:pl-4": !mac(),
"md:pl-2": macTrafficLights(),
"md:pl-4": !macTrafficLights(),
}}
>
<ChannelIndicator debugTools={props.debugTools} />
@@ -528,9 +452,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
</Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
)
}}
@@ -543,14 +464,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
"pl-2": !macTrafficLights(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
@@ -680,12 +600,10 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
</Show>
</div>
</div>
+22 -7
View File
@@ -1,5 +1,11 @@
import { describe, expect, test } from "bun:test"
import { commandPaletteOptions, resolveKeybindOption, upsertCommandRegistration, type CommandOption } from "./command"
import {
activeCommandRegistrations,
addCommandRegistration,
commandPaletteOptions,
resolveKeybindOption,
type CommandOption,
} from "./command"
const paletteOptions: CommandOption[] = [
{ id: "settings.open", title: "Open settings" },
@@ -15,22 +21,31 @@ describe("commandPaletteOptions", () => {
})
})
describe("upsertCommandRegistration", () => {
test("replaces keyed registrations", () => {
describe("command registrations", () => {
test("shadows keyed registrations while retaining the previous owner", () => {
const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
const registrations = addCommandRegistration([{ key: "layout", options: one }], {
key: "layout",
options: two,
})
const active = activeCommandRegistrations(registrations)
expect(next).toHaveLength(1)
expect(next[0]?.options).toBe(two)
expect(registrations).toHaveLength(2)
expect(active).toHaveLength(1)
expect(active[0]?.options).toBe(two)
const restored = activeCommandRegistrations(registrations.filter((entry) => entry.options !== two))
expect(restored).toHaveLength(1)
expect(restored[0]?.options).toBe(one)
})
test("keeps unkeyed registrations additive", () => {
const one = () => [{ id: "one", title: "One" }]
const two = () => [{ id: "two", title: "Two" }]
const next = upsertCommandRegistration([{ options: one }], { options: two })
const next = activeCommandRegistrations(addCommandRegistration([{ options: one }], { options: two }))
expect(next).toHaveLength(2)
expect(next[0]?.options).toBe(two)
+14 -5
View File
@@ -114,9 +114,18 @@ export type CommandRegistration = {
options: Accessor<CommandOption[]>
}
export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
if (entry.key === undefined) return [entry, ...registrations]
return [entry, ...registrations.filter((x) => x.key !== entry.key)]
export function addCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
return [entry, ...registrations]
}
export function activeCommandRegistrations(registrations: CommandRegistration[]) {
const keys = new Set<string>()
return registrations.filter((entry) => {
if (entry.key === undefined) return true
if (keys.has(entry.key)) return false
keys.add(entry.key)
return true
})
}
export function parseKeybind(config: string): Keybind[] {
@@ -281,7 +290,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const seen = new Set<string>()
const all: CommandOption[] = []
for (const reg of store.registrations) {
for (const reg of activeCommandRegistrations(store.registrations)) {
for (const opt of reg.options()) {
if (seen.has(opt.id)) {
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
@@ -425,7 +434,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
key: id,
options,
}
setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
setStore("registrations", (arr) => addCommandRegistration(arr, entry))
onCleanup(() => {
setStore("registrations", (arr) => arr.filter((x) => x !== entry))
})
+2 -1
View File
@@ -134,7 +134,8 @@ export const createDirSyncContext = (
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
await serverSDK.api.session.archive({ sessionID, directory })
if ((await serverSDK.protocol) !== "v1") return
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
current()[1](
"session",
produce((draft) => {
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import {
bootstrapDirectory,
@@ -17,6 +17,8 @@ import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
import type { ServerApi } from "@/utils/server"
type ProjectApi = ServerApi["project"]
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const api = {
agent: { list: async () => ({ location: {}, data: [] }) },
@@ -73,14 +75,14 @@ function directoryState() {
}
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
test("uses legacy MCP endpoints while refreshing a v1 directory", async () => {
const mcpReads: string[] = []
const [store, setStore] = directoryState()
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
mcp: true,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
@@ -90,6 +92,7 @@ describe("bootstrapDirectory", () => {
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: {
list: async () => {
@@ -106,6 +109,14 @@ describe("bootstrapDirectory", () => {
return { data: {} }
},
},
experimental: {
resource: {
list: async () => {
mcpReads.push("resource")
return { data: {} }
},
},
},
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
api,
@@ -115,6 +126,7 @@ describe("bootstrapDirectory", () => {
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
protocol: Promise.resolve("v1"),
})
expect(store.status).toBe("partial")
@@ -122,7 +134,7 @@ describe("bootstrapDirectory", () => {
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
expect(mcpReads).toEqual([])
expect(mcpReads.sort()).toEqual(["command", "resource", "status"])
})
})
@@ -190,7 +202,7 @@ describe("query keys", () => {
calls.push(input)
return {
location: {},
data: [{ name: "review", template: "Review files", source: "command" as const }],
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
}
},
} as unknown as CommandApi
@@ -198,7 +210,7 @@ describe("query keys", () => {
const result = await loadCommands("/repo", api)
expect(calls).toEqual([{ location: { directory: "/repo" } }])
expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }])
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
})
test("loads projects from the current endpoint", async () => {
@@ -16,18 +16,12 @@ import type {
CommandInfo,
CommandListInput,
CommandListOutput,
McpApi,
PathGetInput,
PathGetOutput,
PermissionApi,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
QuestionApi,
ReferenceListInput,
ReferenceListOutput,
SessionApi,
VcsApi,
} from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
@@ -50,6 +44,7 @@ import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { normalizeSessionInfo } from "@/utils/session"
import type { ServerProtocol } from "@/utils/server-protocol"
import type { ServerApi } from "@/utils/server"
type GlobalStore = {
ready: boolean
@@ -121,9 +116,10 @@ type ProjectApi = {
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type PathApi = {
readonly get: (input?: PathGetInput) => Promise<PathGetOutput>
}
type McpApi = ServerApi["mcp"]
type PermissionApi = ServerApi["permission"]
type QuestionApi = ServerApi["question"]
type VcsApi = ServerApi["vcs"]
export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryOptions({
@@ -143,7 +139,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi }
serverAPI: CatalogApi & { readonly project: ProjectApi }
protocol?: Promise<ServerProtocol>
scope: ServerScope
requestFailedTitle: string
@@ -158,7 +154,7 @@ export async function bootstrapGlobal(input: {
input.queryClient.fetchQuery(
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -289,17 +285,26 @@ export const loadCommands = (
agent: command.agent,
model: providerID && id ? { providerID, id } : undefined,
subtask: command.subtask,
source: command.source === "skill" ? undefined : command.source,
// source: command.source === "skill" ? undefined : command.source,
}
})
}
return api.list({ location: { directory } }).then((result) => result.data)
})
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) =>
export const loadPathQuery = (
scope: ServerScope,
directory: string | null,
sdk: OpencodeClient,
protocol?: Promise<ServerProtocol>,
) =>
queryOptions<Path>({
queryKey: [scope, directory, "path"],
queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)),
queryFn: async () => {
if ((await protocol) !== "v1")
return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" }
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
},
})
export const loadReferencesQuery = (
@@ -328,7 +333,6 @@ export async function bootstrapDirectory(input: {
readonly agent: AgentListApi
readonly command: CommandListApi
readonly mcp: McpApi
readonly path: PathApi
readonly permission: PermissionApi
readonly project: ProjectApi
readonly question: QuestionApi
@@ -408,19 +412,20 @@ export async function bootstrapDirectory(input: {
!seededPath &&
(() =>
input.queryClient
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path))
.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol))
.then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
() =>
retry(() =>
input.api.vcs.get({ location: { directory: input.directory } }).then((result) => {
const next = { branch: result.data.branch, default_branch: result.data.defaultBranch }
retry(async () => {
if ((await input.protocol) !== "v1") return
return input.sdk.vcs.get().then((result) => {
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
input.setStore("vcs", next)
if (next) input.vcsCache.setStore("value", next)
}),
),
})
}),
input.mcp &&
(() =>
loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) =>
@@ -503,9 +508,16 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))),
input.mcp &&
(() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))),
(() =>
input.queryClient.fetchQuery(
loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
input.mcp &&
(() =>
input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
)),
() =>
input.queryClient
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
@@ -211,19 +211,19 @@ export function applyDirectoryEvent(input: {
}))
break
}
case "session.archived": {
const properties = event.properties as { sessionID: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
if (!result.found) break
const info = input.store.session[result.index]
input.setStore(
"session",
produce((draft) => void draft.splice(result.index, 1)),
)
cleanupSessionCaches(input.setStore, properties.sessionID)
if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
// case "session.archived": {
// const properties = event.properties as { sessionID: string }
// const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
// if (!result.found) break
// const info = input.store.session[result.index]
// input.setStore(
// "session",
// produce((draft) => void draft.splice(result.index, 1)),
// )
// cleanupSessionCaches(input.setStore, properties.sessionID)
// if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
// break
// }
case "session.moved": {
const properties = event.properties as {
sessionID: string
+16 -10
View File
@@ -570,16 +570,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
continue
}
void serverSdk()
.api.project.update({ projectID: project.id, icon: { color } })
.then((result) =>
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
),
)
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
const projectID = project.id
void (async () => {
const sdk = serverSdk()
if ((await sdk.protocol) !== "v1") return
return sdk.client.project
.update({ projectID, directory: worktree, icon: { color } })
.then((response) => response.data)
.then((result) => {
if (!result) return
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
})
})().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
+1 -1
View File
@@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
name: "Models",
gate: false,
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(props.directory)
const providers = useProviders(() => props.directory?.())
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
+3
View File
@@ -97,6 +97,9 @@ type PlatformBase = {
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
/** Whether the native desktop window is fullscreen */
windowFullscreen?: Accessor<boolean>
/** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
getPinchZoomEnabled?(): Promise<boolean> | boolean
@@ -114,7 +114,7 @@ describe("v2 session reducer", () => {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_1",
structured: {},
metadata: {},
content: [{ type: "text", text: "done" }],
executed: true,
},
@@ -269,13 +269,18 @@ export function createV2SessionReducer() {
...tool,
executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] },
// structured: {}, content: []
state: { status: "running", input: event.data.input, metadata: {} },
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "running"
? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } }
? {
...tool,
// state: { ...tool.state, structured: event.data.structured, content: event.data.content },
state: { ...tool.state, metadata: event.data.metadata },
}
: tool,
)
case "session.tool.success":
@@ -288,9 +293,10 @@ export function createV2SessionReducer() {
state: {
status: "completed",
input: tool.state.input,
structured: event.data.structured,
// structured: event.data.structured,
metadata: event.data.metadata,
content: event.data.content,
result: event.data.result,
// result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
@@ -305,10 +311,11 @@ export function createV2SessionReducer() {
state: {
status: "error",
input: typeof tool.state.input === "string" ? {} : tool.state.input,
structured: tool.state.status === "running" ? tool.state.structured : {},
content: tool.state.status === "running" ? tool.state.content : [],
// structured: tool.state.status === "running" ? tool.state.structured : {},
metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}),
content: event.data.content,
error: event.data.error,
result: event.data.result,
// result: event.data.result,
},
time: { ...tool.time, completed: event.created },
}
@@ -1,8 +1,11 @@
import { describe, expect, test } from "bun:test"
import type { retry } from "@opencode-ai/core/util/retry"
import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise"
import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const session = (id: string, parentID?: string): Session => ({
id,
+8 -5
View File
@@ -1,6 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
Message,
OpencodeClient,
@@ -21,6 +21,9 @@ import { normalizeSessionInfo } from "@/utils/session"
import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
@@ -954,10 +957,10 @@ export function createServerSession(
})
if (event.type === "session.usage.updated" && info)
remember({ ...info, cost: event.data.cost, tokens: event.data.tokens })
if (event.type === "session.archived") {
if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
evict([sessionID])
}
// if (event.type === "session.archived") {
// if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } })
// evict([sessionID])
// }
if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" })
if (
event.type === "session.execution.succeeded" ||
+3 -1
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
McpApi,
McpListInput,
McpResourceCatalogInput,
SessionApi,
@@ -14,6 +13,9 @@ import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/sessio
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
type McpApi = ServerApi["mcp"]
describe("MCP queries", () => {
test("loads current servers for the requested location", async () => {
+9 -2
View File
@@ -188,7 +188,8 @@ function makeQueryOptionsApi(
projects: () => loadProjectsQuery(scope, serverAPI.project),
providers: (directory: PathKey | null) =>
loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol),
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol),
references: (directory: PathKey) =>
loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol),
@@ -289,6 +290,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const queryClient = useQueryClient()
const homeSessions = createHomeSessionIndexCache(queryClient, ServerConnection.key(serverSDK.server))
const refreshProviders = () =>
queryClient.refetchQueries({
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
})
let bootedAt = 0
let bootingRoot = false
@@ -536,6 +541,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
homeSessions.apply(event)
}
homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
@@ -581,7 +587,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
children.mark(key)
if (
event.current?.type === "session.moved" ||
event.current?.type === "session.archived" ||
// event.current?.type === "session.archived" ||
event.current?.type === "session.forked" ||
eventType === "command.updated" ||
eventType === "config.updated" ||
@@ -677,6 +683,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
peek: children.peek,
disableMcp: children.disableMcp,
queryOptions: queryOptionsApi,
refreshProviders,
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
+6 -4
View File
@@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import type { Accessor } from "solid-js"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
export const popularProviders = [
@@ -17,14 +17,14 @@ export const popularProviders = [
]
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory?: Accessor<string | undefined>) {
export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
if (directory)
if (value)
return selectProviderCatalog({
explicit: true,
directory: value,
@@ -37,6 +37,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
global: serverSync().data.provider,
})
}
return {
all: () => providers().all,
default: () => providers().default,
@@ -58,7 +59,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
},
paid: () => {
const connected = new Set(providers().connected)
return [
const paid = [
...Iterable.filter(
providers().all,
([id]) =>
@@ -66,6 +67,7 @@ export function useProviders(directory?: Accessor<string | undefined>) {
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return paid
},
}
}
@@ -1,5 +1,5 @@
import { useDirectoryPicker } from "@/components/directory-picker"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useServerActionsController } from "@/components/server/server-management-controller"
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 = useServerManagementController({ navigateOnAdd: false })
const serverManagement = useServerActionsController()
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.canDefault,
defaultKey: serverManagement.defaultKey,
canDefault: serverManagement.defaults.available,
defaultKey: serverManagement.defaults.key,
setDefault: (conn: ServerConnection.Any | undefined) =>
serverManagement.setDefault(conn ? ServerConnection.key(conn) : null),
remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)),
serverManagement.defaults.set(conn ? ServerConnection.key(conn) : null),
remove: (conn: ServerConnection.Any) => serverManagement.connection.remove(ServerConnection.key(conn)),
edit: (conn: ServerConnection.Http) => dialog.show(() => <DialogServerV2 mode="edit" server={conn} />),
focus: home.selection.focusServer,
},
@@ -360,25 +360,26 @@ function HomeProjectSlot(
index: () => number
},
) {
const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree))
const initial = props.items.find((item) => item.worktree === props.worktree)
if (!initial) return
const project = createMemo<LocalProject>(
(previous) => props.items.find((item) => item.worktree === props.worktree) ?? previous,
initial,
)
return (
<Show when={project()}>
{(item) => (
<HomeProjectRow
{...props}
project={item()}
server={props.server}
index={props.index}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseen={props.unseenCount(props.server, item())}
/>
)}
</Show>
<HomeProjectRow
{...props}
project={project()}
server={props.server}
index={props.index}
serverSelected={props.selection().server === ServerConnection.key(props.server)}
selected={
props.selection().server === ServerConnection.key(props.server) &&
props.selection().directory === props.worktree
}
unseen={props.unseenCount(props.server, project())}
/>
)
}
@@ -211,10 +211,16 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
server: ServerConnection.key(conn),
session,
archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }),
archive: (sessionID) =>
ctx.sdk.client.session.update({
sessionID,
directory: session.directory,
time: { archived: Date.now() },
}),
remove: () =>
setStore(
produce((draft) => {
+28 -12
View File
@@ -119,7 +119,7 @@ export default function LegacyLayout(props: ParentProps) {
const permission = usePermission()
const navigate = useNavigate()
setNavigate(navigate)
const providers = useProviders()
const providers = useProviders(() => undefined)
const dialog = useDialog()
const command = useCommand()
const theme = useTheme()
@@ -872,12 +872,17 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory })
await serverSDK().client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
setStore(
produce((draft) => {
const match = Binary.search(draft.session, session.id, (s) => s.id)
@@ -1296,7 +1301,13 @@ export default function LegacyLayout(props: ParentProps) {
const name = next === getFilename(project.worktree) ? "" : next
if (project.id && project.id !== "global") {
const result = await serverSDK().api.project.update({ projectID: project.id, name })
const sdk = serverSDK()
if ((await sdk.protocol) !== "v1") return
const result = await sdk.client.project
.update({ projectID: project.id, directory: project.worktree, name })
.then((response) => response.data)
if (!result) return
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
serverSync().set("project", (items) =>
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
)
@@ -1479,15 +1490,20 @@ export default function LegacyLayout(props: ParentProps) {
return
}
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.api.session.archive({ sessionID: session.id, directory: session.directory })
.catch(() => undefined),
),
)
if ((await serverSDK().protocol) === "v1")
await Promise.all(
sessions
.filter((session) => session.time.archived === undefined)
.map((session) =>
serverSDK()
.client.session.update({
sessionID: session.id,
directory: session.directory,
time: { archived: Date.now() },
})
.catch(() => undefined),
),
)
setBusy(directory, false)
dismiss()
+19 -274
View File
@@ -1,282 +1,27 @@
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"
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"
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).
*/
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
export default function NewSessionPage() {
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 workspace = createNewSessionWorkspaceController()
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
})
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 project = createPromptProjectController({
controls: draft.project.controls,
onDone: draft.input.restoreFocus,
})
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()
const commands = createNewSessionCommandController({
restoreFocus: draft.input.restoreFocus,
project: {
empty: project.empty,
open: () => project.setOpen(true),
},
get newSessionWorktree() {
return newSessionWorktree()
},
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
onSubmit: () => comments.clear(),
})
const projectController = createPromptProjectController({
controls: projectControls,
onDone: promptInputV2Controller.restoreFocus,
})
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(),
},
])
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>
)
return <NewSession draft={draft} commands={commands} workspace={workspace} project={project} />
}
@@ -0,0 +1,64 @@
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>
@@ -0,0 +1,64 @@
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>
@@ -0,0 +1,165 @@
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>
)
}
@@ -0,0 +1,43 @@
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")
})
})
@@ -0,0 +1,78 @@
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>
@@ -0,0 +1,47 @@
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}
/>
)
}
+47 -45
View File
@@ -6,6 +6,7 @@ import {
batch,
ErrorBoundary,
onCleanup,
Suspense,
Show,
Match,
Switch,
@@ -2217,6 +2218,10 @@ export default function Page() {
comments.clear()
resumeScroll()
},
get edit() {
return editingFollowup()
},
onEditLoaded: clearFollowupEdit,
shouldQueue: queueEnabled,
onQueue: queueFollowup,
onAbort: () => {
@@ -2225,14 +2230,7 @@ export default function Page() {
setFollowup("paused", id, true)
},
})
return (
<PromptInputV2Composer
controller={controller}
borderUnderlay
edit={editingFollowup()}
onEditLoaded={clearFollowupEdit}
/>
)
return <PromptInputV2Composer controller={controller} borderUnderlay />
}}
</Show>
}
@@ -2300,49 +2298,53 @@ export default function Page() {
</div>
<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</Suspense>
</Show>
<Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<div class="min-h-0 flex-1">
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
stacked={desktopV2PanelLayout().stacked}
/>
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
reviewCount={reviewCount}
reviewPanel={reviewPanelV2}
reviewSidebarToggle={(disabled) => (
<SessionReviewV2SidebarToggle
opened={reviewV2State.sidebarOpened()}
disabled={disabled}
onToggle={reviewV2State.toggleSidebar}
/>
)}
fileBrowserState={reviewV2State}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
stacked={desktopV2PanelLayout().stacked}
/>
</Suspense>
</div>
</Show>
<Show when={desktopV2PanelLayout().stacked}>
@@ -25,35 +25,39 @@ export function createPromptInputController(input: {
}) {
const layout = useLayout()
const local = useLocal()
const providers = useProviders()
const sync = useSync()
const sdk = useSDK()
const sync = useSync()
const providers = useProviders(() => sdk().directory)
const view = layout.view(input.sessionKey)
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo<PromptInputControls>(() => ({
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
}))
return createMemo<PromptInputControls>(() => {
return {
agents: {
available: sync().data.agent,
options: local.agent.list().map((agent) => agent.name),
current: local.agent.current()?.name ?? "",
loading: agentsQuery.isLoading,
visible: local.agent.visible(),
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
providersQuery.isLoading ||
globalProvidersQuery.isLoading,
},
session: {
id: input.sessionID(),
tabs: layout.tabs(input.sessionKey),
reviewPanel: view.reviewPanel,
},
}
})
}
export function createPromptProjectControls() {
@@ -812,13 +812,14 @@ export function MessageTimeline(props: {
const archiveSession = async (sessionID: string) => {
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
const sessions = sync().data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk()
.api.session.archive({ sessionID })
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
.then(() => {
sync().set(
produce((draft) => {
@@ -0,0 +1,30 @@
/** Coordinates focus restoration and actions that must run after menu content unmounts. */
export function createMenuDismissController(content: () => HTMLElement | undefined) {
let restoreTrigger = true
return {
/** Allows the menu primitive to restore focus to its trigger when closing. */
allowTriggerRestore() {
restoreTrigger = true
},
/** Keeps focus at its current or next destination instead of returning it to the trigger. */
preventTriggerRestore() {
restoreTrigger = false
},
/** Applies the current restoration policy during the menu primitive's close-focus event. */
onCloseAutoFocus(event: Event) {
if (!restoreTrigger) event.preventDefault()
},
/** Runs an action after the menu unmounts and its focus-close work has settled. */
afterClose(callback: () => void) {
const complete = () => {
if (content()?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
},
}
}
+52 -9
View File
@@ -53,6 +53,7 @@ function setup(
}
describe("createCompatibleApi", () => {
/*
test("routes V1 archive through the legacy session update", async () => {
const { api, requests } = setup("v1")
await api.session.archive({ sessionID: "ses_1", directory: "/repo" })
@@ -63,6 +64,7 @@ describe("createCompatibleApi", () => {
expect(requests[0]!.method).toBe("PATCH")
expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } })
})
*/
test("converts current prompts to the V1 prompt contract", async () => {
const { api, requests } = setup("v1")
@@ -127,14 +129,6 @@ describe("createCompatibleApi", () => {
])
})
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
test("resolves protocol detection once across implementation methods", async () => {
let detections = 0
const resolved = Promise.resolve<"v1" | "v2">("v2")
@@ -147,12 +141,22 @@ describe("createCompatibleApi", () => {
})
const { api } = setup(protocol)
await api.session.archive({ sessionID: "ses_1" })
await api.session.list()
await api.session.list()
expect(detections).toBe(1)
})
/*
test("keeps V2 session actions on the current API", async () => {
const { api, requests } = setup("v2")
await api.session.archive({ sessionID: "ses_1" })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive")
expect(requests[0]!.method).toBe("POST")
})
*/
test("uses the global V1 session search endpoint", async () => {
const { api, requests } = setup("v1")
await api.session.list({ parentID: null, search: "session", limit: 50 })
@@ -160,6 +164,7 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
})
/*
test("projects the V1 default branch", async () => {
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
@@ -167,6 +172,7 @@ describe("createCompatibleApi", () => {
data: { branch: "feature", defaultBranch: "dev" },
})
})
*/
test("translates current file searches to the V1 dirs parameter", async () => {
const { api, requests } = setup("v1")
@@ -190,4 +196,41 @@ describe("createCompatibleApi", () => {
expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1")
expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other")
})
test("disposes the V1 instance after connecting a provider", async () => {
const { api, requests } = setup("v1")
await api.integration.connect.key({
integrationID: "openrouter",
key: "secret",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/auth/openrouter",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
test("disposes the V1 instance after completing provider OAuth", async () => {
const { api, requests } = setup("v1")
await api.integration.oauth.complete({
integrationID: "openrouter",
attemptID: "openrouter:0",
code: "code",
location: { directory: "/repo" },
})
expect(requests.map((request) => new URL(request.url).pathname)).toEqual([
"/provider/openrouter/oauth/callback",
"/instance/dispose",
"/instance/dispose",
])
expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo")
expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull()
})
})
+39 -33
View File
@@ -27,7 +27,7 @@ type CompatibleSessionApi = Omit<
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
}
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
@@ -183,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
},
async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
},
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
// },
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
await legacy(value).session.delete(value)
},
@@ -308,34 +308,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
if (!result.data) throw new Error("Project not found")
return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent
},
async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
const result = await legacy({ directory: project?.worktree }).project.update({
...value,
directory: project?.worktree,
})
if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
return result.data as Project
},
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
// const result = await legacy({ directory: project?.worktree }).project.update({
// ...value,
// directory: project?.worktree,
// })
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
// return result.data as Project
// },
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
const result = await legacy(value.location).worktree.list()
return (result.data ?? []).map((item) => ({ directory: item }))
},
},
path: {
...input.current.path,
async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
const result = await legacy(value?.location).path.get()
if (!result.data) throw new Error("Path unavailable")
return result.data
},
},
// path: {
// ...input.current.path,
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
// const result = await legacy(value?.location).path.get()
// if (!result.data) throw new Error("Path unavailable")
// return result.data
// },
// },
vcs: {
...input.current.vcs,
async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
const result = await legacy(value?.location).vcs.get()
return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
},
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
// const result = await legacy(value?.location).vcs.get()
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
// },
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
const result = await legacy(value?.location).vcs.status()
return located(result.data ?? [], value?.location)
@@ -401,6 +401,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
providerID: value.integrationID,
auth: { type: "api", key: value.key },
})
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
},
oauth: {
@@ -429,6 +431,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method, code: value.code },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
},
status: async (value: Parameters<ServerApi["integration"]["oauth"]["status"]>[0]) => {
const method = Number(value.attemptID.split(":").at(-1))
@@ -436,6 +440,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
{ providerID: value.integrationID, method },
{ throwOnError: true },
)
await legacy(value.location).instance.dispose()
await input.legacy().instance.dispose()
return located(
{ status: "complete" as const, time: { created: Date.now(), expires: Date.now() } },
value.location,
@@ -445,9 +451,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
},
pty: {
...input.current.pty,
async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
},
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
// },
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
},
@@ -479,11 +485,11 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
},
async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
return located(result.data, value.location)
},
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
// return located(result.data, value.location)
// },
},
permission: {
...input.current.permission,
@@ -49,7 +49,7 @@ describe("normalizeSessionMessages", () => {
state: {
status: "completed",
input: { filePath: "note.txt" },
structured: { title: "note.txt" },
metadata: { title: "note.txt" },
content: [{ type: "text", text: "hello" }],
},
time: { created: 5, ran: 6, completed: 7 },
@@ -170,7 +170,7 @@ describe("normalizeSessionMessages", () => {
status: "completed",
input: { path: "/repo/README.md", oldString: "old", newString: "new" },
content: [{ type: "text", text: "Edited file successfully" }],
structured: {
metadata: {
files: [
{
file: "README.md",
+6 -3
View File
@@ -304,7 +304,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
return {
status: "running" as const,
input: normalizeToolInput(tool.name, tool.state.input),
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start },
}
}
@@ -313,7 +314,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
status: "error" as const,
input: normalizeToolInput(tool.name, tool.state.input),
error: tool.state.error.message,
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
}
}
@@ -337,7 +339,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi
input: normalizeToolInput(tool.name, tool.state.input),
output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"),
title: tool.name,
metadata: normalizeToolMetadata(tool.name, tool.state.structured),
// metadata: normalizeToolMetadata(tool.name, tool.state.structured),
metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}),
time: { start, end: tool.time.completed ?? start },
attachments: attachments.length ? attachments : undefined,
}
+10 -12
View File
@@ -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 { useServerManagementController } from "@/components/dialog-select-server"
import type { ServerCollectionController } from "@/components/server/server-management-controller"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -17,8 +17,6 @@ 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"
}
@@ -28,7 +26,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog()
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => <DialogAddWslServer />)
void dialog.push(() => <DialogAddWslServer />)
}
return (
<Show
@@ -67,7 +65,7 @@ export function useFilteredWslServers(filter: Accessor<string>) {
}
export function WslServerSettings(props: {
controller: Controller
domain: Pick<ServerCollectionController, "collection" | "defaults" | "connection">
servers: ReturnType<typeof useFilteredWslServers>
}) {
const platform = usePlatform()
@@ -86,7 +84,7 @@ export function WslServerSettings(props: {
}))
const remove = (key: ServerConnection.Key) => {
request.mutate(() => props.controller.handleRemove(key))
request.mutate(() => props.domain.connection.remove(key))
}
return (
@@ -100,7 +98,7 @@ export function WslServerSettings(props: {
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} />
<ServerHealthIndicator health={props.domain.collection.health()[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>
@@ -114,7 +112,7 @@ export function WslServerSettings(props: {
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<Show when={opencodeAction()}>
@@ -145,13 +143,13 @@ export function WslServerSettings(props: {
{language.t("wsl.server.retryStart")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() !== key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
<Show when={props.domain.defaults.available() && props.domain.defaults.key() === key}>
<MenuV2.Item onSelect={() => props.domain.defaults.set(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.18.4",
"version": "1.18.7",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"scripts": {
@@ -376,7 +376,6 @@
align-items: center;
gap: 16px;
opacity: 0;
transition: opacity 0.2s ease;
@media (max-width: 40rem) {
position: static;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.18.4",
"version": "1.18.7",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.18.4",
"version": "1.18.7",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"scripts": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
@@ -108,7 +108,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.11.1",
"gitlab-ai-provider": "6.12.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -112,7 +112,7 @@ Every field is optional.
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": {}
"environment": {}
},
"remote-thing": {
"type": "remote",
@@ -371,7 +371,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": { "BROWSER": "chromium" }
"environment": { "BROWSER": "chromium" }
},
"github": {
"type": "remote",
@@ -384,7 +384,8 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
}
```
`command` is an array of strings. `type` is required. Use `enabled: false` to
`command` is an array of strings. `environment` sets environment variables for
a local MCP server. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config. String values such as header
tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style
`${VAR}` is not substituted.
+1 -5
View File
@@ -58,7 +58,6 @@ const layer = Layer.effect(
finalize: (draft) =>
Effect.gen(function* () {
materialized.clear()
const seen = new Map<string, string | undefined>()
for (const [name, source] of draft.list()) {
if (source.type === "local") {
materialized.set(
@@ -82,14 +81,11 @@ const layer = Layer.effect(
continue
}
}
const target = Repository.cachePath(global.repos, repository)
if (seen.has(target) && seen.get(target) !== source.branch) continue
seen.set(target, source.branch)
materialized.set(
name,
new Info({
name,
path: AbsolutePath.make(target),
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
...(source.description === undefined ? {} : { description: source.description }),
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
source,
+41 -43
View File
@@ -1,3 +1,10 @@
/**
* Local tracking checkouts for remote Git references, one per remote and
* branch. Each checkout permanently tracks a single ref: the requested branch
* when the cache key has one, otherwise the remote default branch. Content
* follows "newest wins": refresh fetches and hard-resets, so readers may
* observe the checkout move underneath them.
*/
import path from "path"
import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "./fs-util"
@@ -135,7 +142,7 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFl
if (input.branch) yield* validateBranch(input.branch)
const repository = input.reference.label
const localPath = Repository.cachePath(global.repos, input.reference)
const localPath = Repository.cachePath(global.repos, input.reference, input.branch)
const cloneTarget = Repository.parse(input.reference.remote) ?? input.reference
return yield* flock
@@ -143,21 +150,28 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFl
Effect.gen(function* () {
yield* cacheOperation(fs.ensureDir(path.dirname(localPath)), "ensure cache directory", localPath)
const exists = yield* fs.existsSafe(localPath)
const existing = yield* git.repo.discover(AbsolutePath.make(localPath))
const origin = existing ? yield* git.remote.get(existing) : undefined
const originReference = origin ? Repository.parse(origin) : undefined
const reuse = Boolean(existing && originReference && Repository.same(originReference, cloneTarget))
if (exists && !reuse) {
// Discovery walks upward, so an enclosing repository with a
// matching origin could masquerade as the cache entry; reuse
// requires the checkout to live exactly at the cache path.
const worktree = existing ? yield* fs.resolve(localPath) : undefined
const reuse = Boolean(
existing &&
existing.worktree === worktree &&
originReference &&
Repository.same(originReference, cloneTarget),
)
if (!reuse && (yield* fs.existsSafe(localPath))) {
yield* cacheOperation(fs.remove(localPath, { recursive: true }), "remove stale cache", localPath)
}
const currentBranch = reuse && existing ? yield* git.history.branch(existing) : undefined
const status = statusForRepository({
reuse,
refresh: input.refresh,
branchMatches: input.branch ? currentBranch === input.branch : undefined,
})
const status = !reuse
? ("cloned" as const)
: input.refresh
? ("refreshed" as const)
: ("cached" as const)
if (status === "cloned") {
yield* git.repo
@@ -177,25 +191,28 @@ const layer: Layer.Layer<Service, never, FSUtil.Service | Git.Service | EffectFl
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
if (input.branch) {
const requestedBranch = input.branch
yield* git.sync
.fetchBranch(existing, { branch: requestedBranch })
.fetchBranch(existing, { branch: input.branch })
.pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message })))
yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe(
Effect.mapError(
(error) =>
new CheckoutFailedError({
repository,
branch: requestedBranch,
message: error.message,
}),
),
)
}
// Checking out the tracked ref before resetting keeps the
// checkout self-healing even if it was left on another
// branch.
const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing))
if (branch) {
yield* git.sync
.checkoutRemoteBranch(existing, { branch })
.pipe(
Effect.mapError(
(error) => new CheckoutFailedError({ repository, branch, message: error.message }),
),
)
}
const target = branch ?? (yield* git.history.branch(existing))
yield* git.sync
.resetHard(existing, yield* resetTarget(git, existing, input.branch))
.resetHard(existing, target ? `origin/${target}` : "HEAD")
.pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message })))
}
@@ -229,12 +246,6 @@ export const node = makeGlobalNode({
deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node],
})
function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) {
if (!input.reuse) return "cloned" as const
if (input.branchMatches === false || input.refresh) return "refreshed" as const
return "cached" as const
}
function errorMessage(error: unknown) {
return error instanceof globalThis.Error ? error.message : String(error)
}
@@ -245,17 +256,4 @@ function cacheOperation<A, E, R>(effect: Effect.Effect<A, E, R>, operation: stri
)
}
const resetTarget = Effect.fnUntraced(function* (
git: Git.Interface,
repository: Git.Repository,
requestedBranch?: string,
) {
if (requestedBranch) return `origin/${requestedBranch}`
const remoteHead = yield* git.history.defaultRemoteBranch(repository)
if (remoteHead) return `origin/${remoteHead}`
const currentBranch = yield* git.history.branch(repository)
if (currentBranch) return `origin/${currentBranch}`
return "HEAD"
})
export * as RepositoryCache from "./repository-cache"
+8 -2
View File
@@ -118,8 +118,14 @@ export function isRemote(reference: Reference): reference is RemoteReference {
return !isFile(reference)
}
export function cachePath(root: string, reference: Reference): string {
return path.join(root, ...reference.host.split(":"), ...reference.segments)
/**
* Checkouts are keyed by remote and branch: a branch-specific reference gets
* its own directory so branchless refreshes can never move it. The branch is
* percent-encoded because valid branch names may contain `/`.
*/
export function cachePath(root: string, reference: Reference, branch?: string): string {
const base = path.join(root, ...reference.host.split(":"), ...reference.segments)
return branch ? `${base}@${encodeURIComponent(branch)}` : base
}
export function cacheIdentity(reference: Reference): string {
+1 -1
View File
@@ -47,7 +47,7 @@ describe("Reference", () => {
expect(yield* references.list()).toEqual([
new Reference.Info({
name: "sdk",
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)),
path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository, "main")),
source,
}),
])
+36 -1
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { Repository } from "@opencode-ai/core/repository"
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { git, gitRemote } from "./fixture/git"
import { branch, git, gitRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -66,6 +66,41 @@ describe("RepositoryCache", () => {
),
)
it.live("keeps branch checkouts isolated from branchless refreshes", () =>
withRemote((fixture) =>
Effect.gen(function* () {
yield* Effect.promise(() => branch(fixture.source, "feature", "two\n"))
const cache = yield* RepositoryCache.Service
const featured = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
expect(featured.branch).toBe("feature")
expect(featured.localPath.endsWith("repo@feature")).toBe(true)
expect(yield* read(path.join(featured.localPath, "README.md"))).toBe("two\n")
const refreshed = yield* cache.ensure({ reference: fixture.reference, refresh: true })
expect(refreshed.localPath).not.toBe(featured.localPath)
expect(yield* read(path.join(refreshed.localPath, "README.md"))).toBe("one\n")
const cached = yield* cache.ensure({ reference: fixture.reference, branch: "feature" })
expect(cached.status).toBe("cached")
expect(yield* read(path.join(cached.localPath, "README.md"))).toBe("two\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("does not mistake an enclosing repository for the cache checkout", () =>
withRemote((fixture) =>
Effect.gen(function* () {
yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos")))
const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference })
expect(result.status).toBe("cloned")
expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n")
}).pipe(Effect.provide(cacheLayer(fixture.root))),
),
)
it.live("returns typed validation and clone failures", () =>
withRemote((fixture) =>
Effect.gen(function* () {
+6
View File
@@ -17,6 +17,12 @@ describe("Repository", () => {
label: "owner/repo",
})
expect(Repository.cachePath("/cache", reference)).toBe(path.join("/cache", "github.com", "owner", "repo"))
expect(Repository.cachePath("/cache", reference, "main")).toBe(
path.join("/cache", "github.com", "owner", "repo@main"),
)
expect(Repository.cachePath("/cache", reference, "feature/x")).toBe(
path.join("/cache", "github.com", "owner", "repo@feature%2Fx"),
)
expect(Repository.cacheIdentity(reference)).toBe("github.com/owner/repo")
})
@@ -24,6 +24,8 @@ for (const channel of channels) {
expect(config.extraMetadata?.desktopName).toBe(`${channel.appId}.desktop`)
expect(config.linux?.executableName).toBe(channel.appId)
expect(config.linux?.desktop?.entry?.StartupWMClass).toBe(channel.appId)
expect(config.deb?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`))
expect(config.rpm?.fpm).toContainEqual(expect.stringContaining(`/usr/share/metainfo/${channel.appId}.metainfo.xml`))
})
}
@@ -37,8 +39,16 @@ test("keeps a hidden prod launcher for old Linux pins", async () => {
if (previous === undefined) delete process.env.OPENCODE_CHANNEL
else process.env.OPENCODE_CHANNEL = previous
expect(config.deb?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`)
expect(config.rpm?.fpm?.[0]).toEndWith(`${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`)
expect(
config.deb?.fpm?.some((entry) =>
entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"),
),
).toBe(true)
expect(
config.rpm?.fpm?.some((entry) =>
entry.endsWith("opencode-desktop.desktop=/usr/share/applications/opencode-desktop.desktop"),
),
).toBe(true)
const desktop = await Bun.file(legacyDesktopEntry).text()
expect(desktop).toContain("Exec=/opt/OpenCode/ai.opencode.desktop %U")
+9 -4
View File
@@ -15,6 +15,9 @@ const signScript = path.join(rootDir, "script", "sign-windows.ps1")
const legacyDesktopEntry = path.join(packageDir, "resources", "linux", "opencode-desktop.desktop")
const legacyDesktopEntryFpm = `${legacyDesktopEntry}=/usr/share/applications/opencode-desktop.desktop`
const metainfoFpm = (appId: string) =>
`${path.join(packageDir, "resources", `${appId}.metainfo.xml`)}=/usr/share/metainfo/${appId}.metainfo.xml`
async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
if (process.env.GITHUB_ACTIONS !== "true") return
@@ -116,7 +119,8 @@ function getConfig() {
...base,
appId,
productName: "OpenCode Dev",
rpm: { packageName: "opencode-dev" },
deb: { fpm: [metainfoFpm(appId)] },
rpm: { packageName: "opencode-dev", fpm: [metainfoFpm(appId)] },
}
}
case "beta": {
@@ -126,7 +130,8 @@ function getConfig() {
productName: "OpenCode Beta",
protocols: { name: "OpenCode Beta", schemes: ["opencode"] },
publish: { provider: "github", owner: "anomalyco", repo: "opencode-beta", channel: "latest" },
rpm: { packageName: "opencode-beta" },
deb: { fpm: [metainfoFpm(appId)] },
rpm: { packageName: "opencode-beta", fpm: [metainfoFpm(appId)] },
}
}
case "prod": {
@@ -136,8 +141,8 @@ function getConfig() {
productName: "OpenCode",
protocols: { name: "OpenCode", schemes: ["opencode"] },
publish: { provider: "github", owner: "anomalyco", repo: "opencode", channel: "latest" },
deb: { fpm: [legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [legacyDesktopEntryFpm] },
deb: { fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] },
rpm: { packageName: "opencode", fpm: [metainfoFpm(appId), legacyDesktopEntryFpm] },
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

Some files were not shown because too many files have changed in this diff Show More