Compare commits

...
Author SHA1 Message Date
Brendan Allan 8f8617afcd refactor(app): name dialog controller action 2026-07-27 17:57:26 +08:00
Brendan Allan 07d8e2d706 refactor(app): separate model selection effects 2026-07-27 17:41:35 +08:00
Brendan Allan 9d9dfb7b61 refactor(app): use mutation for tab rename 2026-07-27 17:40:51 +08:00
Brendan Allan a10949ffba refactor(app): extract tab rename adapter 2026-07-27 17:40:51 +08:00
opencode-agent[bot] 0b4edfc64c chore: generate 2026-07-27 09:17:56 +00:00
Brendan AllanandGitHub 4469cecc4e refactor(app): move prompt effects into controller (#39073) 2026-07-27 17:15:57 +08:00
Brendan AllanandGitHub 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 ParkerandGitHub 35075bb466 fix(desktop): install Electron before development (#39052) 2026-07-27 14:37:42 +10:00
Luke ParkerandGitHub a9afed0515 fix(desktop): remove titlebar inset in fullscreen (#38793) 2026-07-27 03:54:54 +00:00
Brendan AllanandGitHub 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 AllanandGitHub 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
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
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 AllanandGitHub 0a6637e17a chore(app): vendor v2 client snapshot (#38818) 2026-07-25 08:07:13 +00:00
Brendan AllanandGitHub 2b2b69d668 fix(app): refresh V1 MCP state (#38816) 2026-07-25 15:01:18 +08:00
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 LangtonandGitHub 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 ClineandGitHub 2b2aacc939 fix(provider): generalize Claude adaptive thinking (#38757) 2026-07-24 16:44:00 -05:00
Aiden ClineandGitHub 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 ClineandGitHub 4b19ea2a71 fix(llm): preserve response message phases (#38452) 2026-07-24 16:05:22 -05:00
100 changed files with 1156 additions and 685 deletions
+32 -32
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",
},
@@ -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",
},
@@ -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",
@@ -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:*",
@@ -159,7 +159,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 +231,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: "",
@@ -391,7 +391,7 @@ function ProviderConnection(props: {
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()
@@ -857,6 +857,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 +874,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,7 +6,6 @@ import {
createMemo,
For,
JSX,
onCleanup,
Show,
ValidComponent,
} from "solid-js"
@@ -242,42 +241,111 @@ export function ModelSelectorPopoverV2(props: {
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const controller = createModelSelectorController({
model: props.model,
provider: () => props.provider,
onSelect: () => props.onClose?.(),
})
return (
<ModelSelectorPopoverV2View
children={props.children}
triggerAs={props.triggerAs}
triggerProps={props.triggerProps}
models={controller.models}
groups={controller.groups}
current={controller.current}
select={controller.select}
openManageModelsDialog={controller.openManageModelsDialog}
labels={controller.labels}
onClose={() => props.onClose?.()}
/>
)
}
function createModelSelectorController(input: {
provider: () => string | undefined
model?: ModelState
onSelect: () => void
}) {
const model = input.model ?? useLocal().model
const dialog = useDialog()
const language = useLanguage()
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
)
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()
},
openManageModelsDialog: () => {
void import("./dialog-manage-models").then((module) => {
void dialog.show(() => <module.DialogManageModelsV2 />)
})
},
labels: {
search: () => language.t("dialog.model.search.placeholder"),
empty: () => language.t("dialog.model.empty"),
clear: () => language.t("common.clear"),
free: () => language.t("model.tag.free"),
latest: () => language.t("model.tag.latest"),
manage: () => language.t("dialog.model.manage"),
},
}
}
function ModelSelectorPopoverV2View(props: {
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
models: (search: string) => ModelItem[]
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
current: () => string | undefined
select: (item: ModelItem) => void
openManageModelsDialog: () => void
labels: {
search: () => string
empty: () => string
clear: () => string
free: () => string
latest: () => string
manage: () => string
}
onClose: () => void
}) {
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const allModels = createMemo(() =>
model
.list()
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
)
const models = createMemo(() => {
const search = store.search.trim()
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 models = createMemo(() => props.models(store.search))
const groups = createMemo(() => props.groups(models()))
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
}
const initialActive = () => {
const selected = current()
const selected = props.current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
@@ -308,23 +376,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
setOpen(false)
afterClose(() => select(item))
afterClose(() => props.select(item))
}
const manage = () => {
restoreTrigger = false
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
afterClose(props.openManageModelsDialog)
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
@@ -343,10 +403,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 })
}
@@ -381,7 +438,7 @@ export function ModelSelectorPopoverV2(props: {
<input
ref={(el) => (searchRef = el)}
value={store.search}
placeholder={language.t("dialog.model.search.placeholder")}
placeholder={props.labels.search()}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
spellcheck={false}
autocorrect="off"
@@ -395,7 +452,7 @@ export function ModelSelectorPopoverV2(props: {
event.preventDefault()
restoreTrigger = false
setOpen(false)
afterClose(() => props.onClose?.())
afterClose(props.onClose)
return
}
if (event.altKey || event.metaKey) return
@@ -421,7 +478,7 @@ export function ModelSelectorPopoverV2(props: {
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
aria-label={props.labels.clear()}
>
<Icon name="close" size="small" />
</button>
@@ -435,7 +492,7 @@ export function ModelSelectorPopoverV2(props: {
when={models().length > 0}
fallback={
<div class="flex h-12 items-center px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
{language.t("dialog.model.empty")}
{props.labels.empty()}
</div>
}
>
@@ -445,7 +502,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 +522,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={() => {
@@ -476,10 +533,10 @@ export function ModelSelectorPopoverV2(props: {
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
<TagV2 class="shrink-0">{props.labels.free()}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
<TagV2 class="shrink-0">{props.labels.latest()}</TagV2>
</Show>
</MenuV2.RadioItem>
</TooltipV2>
@@ -504,7 +561,7 @@ export function ModelSelectorPopoverV2(props: {
onSelect={manage}
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
<span class="min-w-0 flex-1 truncate leading-5">{props.labels.manage()}</span>
</MenuV2.Item>
</div>
</MenuV2.Content>
+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)),
)
+60 -71
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"
@@ -542,6 +530,7 @@ function PromptInputV2ModelControl(props: {
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",
}}
onClose={props.onClose}
>
+1 -1
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]
@@ -364,41 +364,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">
@@ -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[] },
)
@@ -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,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 />
@@ -127,7 +127,8 @@ export const SettingsGeneralV2: 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[] },
)
@@ -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(() => {
+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 () => {
+3 -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),
@@ -581,7 +582,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" ||
+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
},
}
}
@@ -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()
+8
View File
@@ -126,6 +126,14 @@ export default function NewSessionPage() {
keybind: "ctrl+l",
onSelect: () => promptInputV2Controller.restoreFocus(),
},
{
id: "project.select",
title: language.t("session.new.project.search"),
category: language.t("command.category.project"),
keybind: "mod+shift+o",
disabled: projectController.empty(),
onSelect: () => projectController.setOpen(true),
},
])
createEffect(() => {
+5 -8
View File
@@ -2217,6 +2217,10 @@ export default function Page() {
comments.clear()
resumeScroll()
},
get edit() {
return editingFollowup()
},
onEditLoaded: clearFollowupEdit,
shouldQueue: queueEnabled,
onQueue: queueFollowup,
onAbort: () => {
@@ -2225,14 +2229,7 @@ export default function Page() {
setFollowup("paused", id, true)
},
})
return (
<PromptInputV2Composer
controller={controller}
borderUnderlay
edit={editingFollowup()}
onEditLoaded={clearFollowupEdit}
/>
)
return <PromptInputV2Composer controller={controller} borderUnderlay />
}}
</Show>
}
@@ -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) => {
+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,
}
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": {
+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": {
+1 -1
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",
+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")
})
+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",
+2
View File
@@ -1,5 +1,7 @@
import { $ } from "bun"
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await $`cd ../opencode && bun script/build-node.ts`
+5
View File
@@ -227,6 +227,11 @@ export function registerIpcHandlers(deps: Deps) {
return win?.isFocused() ?? false
})
ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
+11
View File
@@ -219,6 +219,7 @@ export function createMainWindow(id: string = randomUUID()) {
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
@@ -472,6 +473,16 @@ function wireZoom(win: BrowserWindow) {
})
}
function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
win.webContents.send("window-fullscreen-changed", fullscreen)
}
win.on("enter-full-screen", () => send(true))
win.on("leave-full-screen", () => send(false))
}
function clampZoom(value: number) {
return Math.min(Math.max(value, minZoomLevel), maxZoomLevel)
}
+6
View File
@@ -100,6 +100,12 @@ const api: ElectronAPI = {
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"),
onWindowFullscreenChanged: (cb) => {
const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen)
ipcRenderer.on("window-fullscreen-changed", handler)
return () => ipcRenderer.removeListener("window-fullscreen-changed", handler)
},
setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
showWindow: () => ipcRenderer.invoke("show-window"),
relaunch: () => ipcRenderer.send("relaunch"),
+2
View File
@@ -91,6 +91,8 @@ export type ElectronAPI = {
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
getWindowFullscreen: () => Promise<boolean>
onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void
setWindowFocus: () => Promise<void>
showWindow: () => Promise<void>
relaunch: () => void
+3
View File
@@ -25,6 +25,7 @@ import { initI18n, t } from "./i18n"
import { initializationData, initializationReady } from "./initialization"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import { windowFullscreen } from "./window-fullscreen"
import { availableStartupServer, readyWslConnections } from "./wsl/connections"
import "./styles.css"
import { Splash } from "@opencode-ai/ui/logo"
@@ -294,6 +295,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
webviewZoom,
windowFullscreen,
getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(),
setPinchZoomEnabled,
@@ -0,0 +1,8 @@
import { createSignal } from "solid-js"
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
window.api.onWindowFullscreenChanged(setWindowFullscreen)
void window.api.getWindowFullscreen().then(setWindowFullscreen)
export { windowFullscreen }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"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/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 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.18.4",
"version": "1.18.7",
"name": "opencode",
"type": "module",
"license": "MIT",
+36 -17
View File
@@ -635,24 +635,23 @@ function openaiCompatibleReasoningEfforts(id: string) {
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
function anthropicOpus47OrLater(apiId: string) {
// Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted).
// Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility.
const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId)
if (!version) return false
const major = Number(version[1] ?? version[3])
const minor = Number(version[2] ?? version[4])
function anthropicUsesModernAdaptiveThinking(apiId: string) {
if (!apiId.toLowerCase().includes("claude-")) return false
// Covers family-first IDs such as claude-opus-4.7 and version-first IDs such as claude-4.7-opus.
// Limit minors to two digits so release dates in IDs such as claude-opus-4-20250514 are not versions.
const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId)
if (!version) return true
const major = Number(version[1])
const minor = Number(version[2] ?? 0)
return major > 4 || (major === 4 && minor >= 7)
}
function anthropicSonnet5OrLater(apiId: string) {
const version = /sonnet-(\d+)(?:[.@-]|$)|claude-(\d+)-sonnet(?:[.@-]|$)/i.exec(apiId)
if (!version) return false
return Number(version[1] ?? version[2]) >= 5
function anthropicOpus45(apiId: string) {
return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value))
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")) {
if (anthropicUsesModernAdaptiveThinking(apiId)) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (
@@ -666,7 +665,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null {
}
function anthropicOmitsThinking(apiId: string) {
return anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")
return anthropicUsesModernAdaptiveThinking(apiId)
}
function googleThinkingLevelEfforts(apiId: string) {
@@ -990,8 +989,10 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
)
}
if (["opus-4-5", "opus-4.5"].some((v) => model.api.id.includes(v))) {
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }]))
if (anthropicOpus45(model.api.id)) {
return Object.fromEntries(
WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, anthropicOpus45Effort(model, effort)]),
)
}
return {
@@ -1103,7 +1104,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (id.includes("anthropic")) {
if (adaptiveEfforts) {
// Bedrock adaptive splits `effort` out into `output_config` (vs Anthropic
// native which inlines it). Opus 4.7+ flipped `display` default to "omitted".
// native which inlines it). Claude 4.7+ defaults `display` to "omitted".
return wrapInSapModelParams(
Object.fromEntries(
adaptiveEfforts.map((effort) => [
@@ -1711,6 +1712,14 @@ function reasoningEffort(model: Provider.Model, effort: string) {
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
}
if (anthropicOpus45(model.api.id))
return {
reasoningConfig: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
maxReasoningEffort: effort,
},
}
if (model.api.id.includes("anthropic")) return
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
case "@ai-sdk/gateway":
@@ -1751,7 +1760,7 @@ function reasoningEffort(model: Provider.Model, effort: string) {
}
function anthropicEffort(model: Provider.Model, effort: string) {
if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort }
if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort)
// Kimi defaults to omitting adaptive thinking text unless summarized display is requested.
if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (!anthropicAdaptiveEfforts(model.api.id)) return
@@ -1764,6 +1773,16 @@ function anthropicEffort(model: Provider.Model, effort: string) {
}
}
function anthropicOpus45Effort(model: Provider.Model, effort: string) {
return {
thinking: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
},
effort,
}
}
function reasoningBudget(model: Provider.Model, budget: number) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
@@ -3213,6 +3213,7 @@ describe("ProviderTransform.reasoningVariants", () => {
{ thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
"claude-opus-4-7",
],
["@ai-sdk/anthropic", { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, "claude-opus-5"],
["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }],
[
@@ -3264,13 +3265,18 @@ describe("ProviderTransform.reasoningVariants", () => {
)
})
test("uses bare effort for Claude Opus 4.5", () => {
test("combines effort with extended thinking for Claude Opus 4.5", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/anthropic", "claude-opus-4-5"),
),
).toEqual({ high: { effort: "high" } })
).toEqual({
high: {
thinking: { type: "enabled", budgetTokens: 16_000 },
effort: "high",
},
})
})
test("uses explicit effort metadata for Anthropic-compatible models", () => {
@@ -3319,6 +3325,38 @@ describe("ProviderTransform.reasoningVariants", () => {
})
})
test("uses adaptive reasoning config for Claude Opus 5 on Bedrock", () => {
const result = ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }]),
target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-5"),
)
expect(Object.keys(result ?? {})).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result?.high).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
})
})
test("combines effort with extended thinking for Claude Opus 4.5 on Bedrock", () => {
expect(
ProviderTransform.reasoningVariants(
model([{ type: "effort", values: ["high"] }]),
target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-4-5-20251101-v1:0"),
),
).toEqual({
high: {
reasoningConfig: {
type: "enabled",
budgetTokens: 16_000,
maxReasoningEffort: "high",
},
},
})
})
test("does not replace unsupported Anthropic Bedrock effort options with token budgets", () => {
expect(
ProviderTransform.reasoningVariants(
@@ -4617,11 +4655,17 @@ describe("ProviderTransform.variants", () => {
describe("@ai-sdk/anthropic", () => {
for (const testCase of [
{
name: "opus 4 dated",
apiIds: ["claude-opus-4-20250514"],
efforts: ["high", "max"],
expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 } },
},
{
name: "opus 4.5",
apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"],
efforts: ["low", "medium", "high"],
expectedHigh: { effort: "high" },
expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 }, effort: "high" },
},
{
name: "sonnet 4.6",
@@ -4653,6 +4697,18 @@ describe("ProviderTransform.variants", () => {
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "opus 5",
apiIds: ["claude-opus-5", "claude-opus-5-20260724"],
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "unversioned future model",
apiIds: ["claude-future"],
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "fable 5",
apiIds: ["claude-fable-5"],
@@ -4772,6 +4828,28 @@ describe("ProviderTransform.variants", () => {
effort: "high",
})
})
test("opus 5 uses adaptive reasoning for Vertex model IDs", () => {
const result = ProviderTransform.variants(
createMockModel({
id: "google-vertex-anthropic/claude-opus-5@default",
providerID: "google-vertex-anthropic",
api: {
id: "claude-opus-5@default",
url: "https://us-central1-aiplatform.googleapis.com",
npm: "@ai-sdk/google-vertex/anthropic",
},
}),
)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.high).toEqual({
thinking: {
type: "adaptive",
display: "summarized",
},
effort: "high",
})
})
})
describe("@ai-sdk/amazon-bedrock", () => {
@@ -4867,6 +4945,28 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 5 returns adaptive reasoning options with xhigh", () => {
const result = ProviderTransform.variants(
createMockModel({
id: "bedrock/anthropic-claude-opus-5",
providerID: "bedrock",
api: {
id: "us.anthropic.claude-opus-5-v1:0",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
}),
)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.high).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
@@ -5055,6 +5155,12 @@ describe("ProviderTransform.variants", () => {
efforts: ["low", "medium", "high", "xhigh", "max"],
thinking: { type: "adaptive", display: "summarized" },
},
{
name: "opus 5",
apiIds: ["anthropic--claude-opus-5", "anthropic--claude-5-opus"],
efforts: ["low", "medium", "high", "xhigh", "max"],
thinking: { type: "adaptive", display: "summarized" },
},
]) {
for (const apiId of testCase.apiIds) {
test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => {
@@ -51,7 +51,7 @@ describe("reference HttpApi", () => {
},
{
name: "effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect@main"),
source: {
type: "git",
repository: "Effect-TS/effect",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
@@ -39,7 +39,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:*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-server",
"version": "1.18.4",
"version": "1.18.7",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/tui",
"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/ui",
"version": "1.18.4",
"version": "1.18.7",
"type": "module",
"license": "MIT",
"repository": {
@@ -121,6 +121,7 @@ export function SelectV2<T>(props: SelectV2Props<T>) {
<Kobalte<T, { category: string; options: T[] }>
{...others}
multiple={false}
allowDuplicateSelectionEvents={false}
disabled={local.disabled}
data-component="select-v2-root"
placement={local.placement ?? (inline() ? "bottom-end" : "bottom-start")}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.18.4",
"version": "1.18.7",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.18.4",
"version": "1.18.7",
"publisher": "sst-dev",
"repository": {
"type": "git",