Compare commits

..

38 Commits

Author SHA1 Message Date
opencode 10c894bdee release: v1.17.13 2026-07-01 15:18:52 +00:00
Aarav Sareen 6697cf3fd8 feat(app): minor fixes (#34748) 2026-07-01 21:10:53 +08:00
opencode-agent[bot] 87aa62d1e2 chore: generate 2026-07-01 12:41:15 +00:00
usrnk1 3136b1ba97 feat(desktop): improve markdown heading spacing in session timeline (#34738) 2026-07-01 20:39:12 +08:00
Aarav Sareen 246dea3a58 feat(app): more v2 ui alignment (#34465)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 12:14:51 +00:00
opencode-agent[bot] 4b1c7f28a0 chore: generate 2026-07-01 12:08:40 +00:00
Aarav Sareen 9410eda4e3 feat(app): composer improvements (#34720)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 12:06:41 +00:00
usrnk1 f014686b07 feat(desktop): session tab hover preview popover (#34678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-07-01 17:16:45 +08:00
opencode-agent[bot] 8b68dc0d7f chore: generate 2026-07-01 09:05:06 +00:00
Brendan Allan 2f4d36d64e fix(desktop): clean stale draft stores (#34651) 2026-07-01 09:03:27 +00:00
opencode-agent[bot] 6f2ff35656 chore: generate 2026-07-01 08:17:22 +00:00
Julian Coy cd00422298 fix(app): question UI fixes and UX improvements (#34116)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
2026-07-01 08:15:31 +00:00
opencode-agent[bot] 34ee94232f chore: generate 2026-07-01 07:40:25 +00:00
Aarav Sareen 4d91f0cf28 feat(app): v2 wsl ui (#34233)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-07-01 07:39:00 +00:00
opencode-agent[bot] bf18cc971f chore: generate 2026-07-01 07:06:04 +00:00
Brendan Allan 5e1a1ed2e2 fix(app): scope session page errors (#34254) 2026-07-01 15:04:18 +08:00
opencode-agent[bot] bd7f7ad98b chore: generate 2026-07-01 07:03:41 +00:00
Brendan Allan af72cec799 feat(desktop): scope tabs to windows (#34669) 2026-07-01 07:02:10 +00:00
Aiden Cline 55552c521f fix(provider): force openai reasoning variants (#34702) 2026-06-30 22:45:39 -05:00
opencode-agent[bot] 2b611a5b14 chore: update nix node_modules hashes 2026-07-01 01:44:56 +00:00
opencode-agent[bot] 45a437cbb2 fix(app): clamp virtualizer range to scroll bounds (#34588)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-01 11:28:34 +10:00
opencode-agent[bot] 439ec9b76f chore: generate 2026-07-01 00:53:45 +00:00
opencode-agent[bot] ae3fefb42a fix(session-ui): recognize more inline file paths (#34688)
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-07-01 00:52:10 +00:00
Aiden Cline f1407e41c4 fix(core): stop replaying stale GitHub Copilot Responses item IDs (#34686) 2026-06-30 19:19:02 -05:00
opencode-agent[bot] c88ca972cf chore: generate 2026-06-30 23:59:52 +00:00
Frank 928c37bc02 zen: kk2.7, mm3, sonnet5 2026-06-30 19:56:25 -04:00
opencode afff74eb2c sync release versions for v1.17.12 2026-06-30 19:48:01 +00:00
Aiden Cline 3a669d528b fix(provider): enable sonnet 5 adaptive thinking (#34673) 2026-06-30 14:03:05 -05:00
Jack 6789214b31 fix(console): remove expired Go MiniMax promo (#34663) 2026-07-01 01:42:05 +08:00
James Long 6636683323 refactor: remove remaining default layer aliases (#34660) 2026-06-30 13:17:34 -04:00
opencode-agent[bot] 3af9f64265 chore: generate 2026-06-30 17:07:39 +00:00
James Long 5a23bdcb69 refactor(core): remove domain layer exports (#34625) 2026-06-30 13:05:15 -04:00
opencode-agent[bot] e78726854a chore: generate 2026-06-30 16:19:11 +00:00
James Long 472d0f376e refactor(core): remove infrastructure layer exports (#34624) 2026-06-30 12:17:12 -04:00
Brendan Allan c8fde60ad4 fix(app): hide missing workspace branch (#34649) 2026-07-01 00:05:09 +08:00
opencode-agent[bot] a4b6047e64 fix(core): drop legacy config filename (#34645)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-06-30 11:03:53 -05:00
James Long 6387f955cf refactor(core): remove session layer exports (#34623) 2026-06-30 11:44:08 -04:00
James Long 5691f36e98 refactor(core): remove tool layer exports (#34622) 2026-06-30 11:18:41 -04:00
244 changed files with 6949 additions and 2377 deletions
+27 -27
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
@@ -92,7 +92,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -140,7 +140,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -176,7 +176,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -203,7 +203,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -225,7 +225,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -249,7 +249,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -269,7 +269,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -363,7 +363,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -417,7 +417,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -431,7 +431,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"effect": "catalog:",
},
@@ -443,7 +443,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -475,7 +475,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -491,7 +491,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -522,7 +522,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -541,7 +541,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.11",
"version": "1.17.13",
"bin": {
"opencode": "./bin/opencode",
},
@@ -671,7 +671,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -747,7 +747,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -762,7 +762,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -777,7 +777,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -821,7 +821,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -834,7 +834,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -867,7 +867,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -886,7 +886,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -927,7 +927,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -954,7 +954,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1005,7 +1005,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.11",
"version": "1.17.13",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-An7vZmA+sVlxoSOMK4vQvkq3KJOF6Rm9IBU62LN3Hkg=",
"aarch64-linux": "sha256-Hx4trrBLxRDyY4HQc96EQZUa8abIeetNvE8GtRFvrrc=",
"aarch64-darwin": "sha256-/eXdkybUkDCrfQRCkZHsgCa3BM/OfaQzH/z7OxwlpVM=",
"x86_64-darwin": "sha256-flhsuFXikpI8tzK7nTwIVF/jzpmb5p+BWxGa/5vhN54="
"x86_64-linux": "sha256-Ulflihjr8JDRVyNxSchoqaey6z/12256zs1FOiw+4vo=",
"aarch64-linux": "sha256-wP6p30a9f7s3tua5qbIq/10OHdbJb8xUni5KxE3F+/k=",
"aarch64-darwin": "sha256-7+4skFiJ+tLSjNG6WR53bT/IektA5B7gEcWLYpkK4z8=",
"x86_64-darwin": "sha256-NL945zD4J6HomH5SF0XIO8ZFCbIh2MTsJFtTyuxm2Q0="
}
}
@@ -45,7 +45,7 @@ export async function installStressSessionTabs(page: Page, input?: { draftID?: s
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
...sessionIDs.map((sessionId) => ({
type: "session",
@@ -14,7 +14,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server: "http://127.0.0.1:4096", sessionId: sessionA },
{ type: "session", server: serverB, sessionId: sessionB },
@@ -52,7 +52,7 @@ test("legacy session routes preserve an existing tab's server", async ({ page })
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
)
},
@@ -66,7 +66,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
await expect(control).toBeVisible()
await control.locator('[data-action="prompt-model-variant"]').click()
const high = page.getByRole("option", { name: "high" })
const high = page.getByRole("menuitemradio", { name: "high" })
await expect(high).toBeVisible()
await page.mouse.move(0, 0)
await expect(control).toBeVisible()
@@ -38,6 +38,29 @@ test("shows a pending question dock", async ({ page }) => {
await expect(question.getByRole("radio", { name: /Extended/ })).toBeVisible()
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
})
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByText("Select one answer")).toBeHidden()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeHidden()
await expect(question.getByRole("radio", { name: /Extended/ })).toBeHidden()
await expect(question.getByRole("button", { name: "Dismiss" })).toBeVisible()
await expect(question.getByRole("button", { name: "Submit" })).toBeVisible()
await expect(page.locator('[data-component="question-minimized-dock"]')).toHaveCount(0)
expect(rejectRequests).toEqual([])
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
await expect(question).toBeVisible()
await expect(question.getByText("Which implementation should be used?")).toBeVisible()
await expect(question.getByRole("radio", { name: /Minimal/ })).toBeVisible()
expect(rejectRequests).toEqual([])
await question.getByRole("radio", { name: /Minimal/ }).click()
const reply = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
@@ -145,7 +145,7 @@ async function configurePage(page: Page) {
}),
)
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
)
},
@@ -127,7 +127,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
@@ -253,7 +253,7 @@ test.describe("smoke: session timeline", () => {
await page.addInitScript(
({ dirBase64, sourceID, targetID }) => {
localStorage.setItem(
"opencode.global.dat:tabs",
"opencode.window.browser.dat:tabs",
JSON.stringify(
[sourceID, targetID].map((sessionId) => ({
type: "session",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.11",
"version": "1.17.13",
"description": "",
"type": "module",
"exports": {
+10 -109
View File
@@ -31,20 +31,19 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk"
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider, useNotification } from "@/context/notification"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
@@ -53,16 +52,9 @@ import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import {
legacySessionHref,
legacySessionServer,
requireServerKey,
selectSessionLineage,
sessionHref,
} from "./utils/session-route"
import { isSessionNotFoundError } from "./utils/server-errors"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import Session from "@/pages/session"
import { SessionPage, TargetSessionRoute as TargetSessionRouteContent } from "@/pages/session"
import { NewHome, LegacyHome } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -96,11 +88,7 @@ const SessionRoute = () => {
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
})
return (
<SessionProviders>
<Session />
</SessionProviders>
)
return <SessionPage />
}
const TargetSessionRoute = () => {
@@ -115,76 +103,13 @@ const TargetSessionRoute = () => {
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ResolvedTargetSessionRoute />
<TargetSessionRouteContent />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, server: serverKey(), sync: sync() }
},
({ id, server, sync }) =>
sync.session.lineage.resolve(id).catch((error) => {
if (isSessionNotFoundError(error, id)) tabs.removeSessionTab({ server, sessionId: id })
throw error
}),
)
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
if (!session) return
tabs.addSessionTab({
server: serverKey(),
sessionId: session.root.id,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={!!current() || resolved.state !== "errored"} fallback={<ErrorPage error={resolved.error} />}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionProviders>
<Session />
</SessionProviders>
</Show>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
// Notification/Models + the visual Layout) for that server.
@@ -232,7 +157,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<TargetServerScopedProviders directory={directory}>
<DraftServerScopedProviders directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
@@ -240,7 +165,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</TargetServerScopedProviders>
</DraftServerScopedProviders>
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
@@ -365,38 +290,14 @@ function NewAppLayout(props: ParentProps) {
)
}
function TargetServerScopedProviders(props: ServerScopedShellProps) {
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
return (
<PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
@@ -3,13 +3,26 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog as DialogV2, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Switch as SwitchV2 } from "@opencode-ai/ui/v2/switch-v2"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { For, Show, type Component } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { useLanguage } from "@/context/language"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "./dialog-select-provider"
import { decode64 } from "@/utils/base64"
import { SettingsListV2 } from "./settings-v2/parts/list"
import { SettingsRowV2 } from "./settings-v2/parts/row"
import "./settings-v2/settings-v2.css"
type ModelItem = ReturnType<ReturnType<typeof useLocal>["model"]["list"]>[number]
export const DialogManageModels: Component = () => {
const local = useLocal()
@@ -102,3 +115,151 @@ export const DialogManageModels: Component = () => {
</Dialog>
)
}
export const DialogManageModelsV2: Component = () => {
const local = useLocal()
const language = useLanguage()
const dialog = useDialog()
const directory = () => decode64(local.slug())
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider directory={directory} />)
}
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
const setModelVisibility = (item: ModelItem, checked: boolean) => {
local.model.setVisibility({ modelID: item.id, providerID: item.provider.id }, checked)
}
const list = useFilteredList<ModelItem>({
items: () => local.model.list(),
key: (x) => `${x.provider.id}:${x.id}`,
filterKeys: ["provider.name", "name", "id"],
sortBy: (a, b) => a.name.localeCompare(b.name),
groupBy: (x) => x.provider.id,
sortGroupsBy: (a, b) => {
const aRank = popularProviders.indexOf(a.category)
const bRank = popularProviders.indexOf(b.category)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
},
})
return (
<DialogV2 size="large" variant="settings" class="settings-v2-manage-models-dialog">
<DialogHeader hideClose={true} closeLabel={language.t("common.close")}>
<DialogTitleGroup
title={language.t("dialog.model.manage")}
description={language.t("dialog.model.manage.description")}
/>
<ButtonV2 variant="neutral" icon="plus" onClick={handleConnectProvider}>
{language.t("command.provider.connect")}
</ButtonV2>
</DialogHeader>
<DialogBody class="flex min-h-0 flex-1 flex-col">
<div class="px-4 pt-px pb-3">
<div class="relative">
<TextInputV2
type="search"
appearance="base"
class="!w-full self-stretch"
value={list.filter()}
onInput={(event) => list.onInput(event.currentTarget.value)}
placeholder={language.t("dialog.model.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
autofocus
aria-label={language.t("dialog.model.search.placeholder")}
/>
<Show when={list.filter()}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => list.clear()}
aria-label={language.t("common.clear")}
/>
</Show>
</div>
</div>
<div data-slot="manage-models-scroll" class="relative min-h-0 flex-1">
<div class="settings-v2-panel settings-v2-models h-full px-4 pt-4 pb-4">
<Show
when={!list.grouped.loading}
fallback={
<div class="settings-v2-models-status">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</div>
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="settings-v2-models-status">
<span>{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="settings-v2-models-status-filter">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
>
<For each={list.grouped.latest}>
{(group) => (
<div class="settings-v2-section" data-component="settings-models-provider">
<div class="settings-v2-models-group-header justify-between">
<div class="flex min-w-0 items-center gap-2">
<ProviderIcon id={group.category} width={16} height={16} class="ml-4 shrink-0" />
<h3 class="settings-v2-section-title">{group.items[0].provider.name}</h3>
</div>
<div>
<SwitchV2
class="mr-6"
checked={providerVisible(group.category)}
onChange={(checked) => setProviderVisibility(group.category, checked)}
hideLabel
>
{group.items[0].provider.name}
</SwitchV2>
</div>
</div>
<SettingsListV2>
<For each={group.items}>
{(item) => (
<SettingsRowV2 title={item.name} description="">
<div>
<SwitchV2
checked={local.model.visible({ modelID: item.id, providerID: item.provider.id })}
onChange={(checked) => setModelVisibility(item, checked)}
hideLabel
>
{item.name}
</SwitchV2>
</div>
</SettingsRowV2>
)}
</For>
</SettingsListV2>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</DialogBody>
</DialogV2>
)
}
@@ -1,15 +1,19 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createMemo, For, JSX, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
@@ -18,6 +22,22 @@ const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
type ModelState = ReturnType<typeof useLocal>["model"]
type ModelItem = ReturnType<ModelState["list"]>[number]
const modelKey = (model: ModelItem) => `${model.provider.id}:${model.id}`
const manageKey = "action:manage"
const sortModelGroups = (a: { category: string; items: ModelItem[] }, b: { category: string; items: ModelItem[] }) => {
const aIndex = popularProviders.indexOf(a.category)
const bIndex = popularProviders.indexOf(b.category)
const aPopular = aIndex >= 0
const bPopular = bIndex >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
if (aPopular && bPopular) return aIndex - bIndex
return a.items[0].provider.name.localeCompare(b.items[0].provider.name)
}
const ModelList: Component<{
provider?: string
@@ -199,6 +219,271 @@ export function ModelSelectorPopover(props: {
)
}
export function ModelSelectorPopoverV2(props: {
provider?: string
model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
onClose?: () => void
}) {
const model = props.model ?? useLocal().model
const language = useLanguage()
const dialog = useDialog()
const [store, setStore] = createStore({ open: false, search: "", active: "" })
let searchRef: HTMLInputElement | undefined
let contentRef: HTMLDivElement | undefined
let restoreTrigger = true
const 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().toLowerCase()
const filtered = search
? allModels().filter(
(item) =>
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
: allModels()
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
})
const groups = createMemo(() => {
const byProvider = new Map<string, ModelItem[]>()
for (const item of models()) {
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
}
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
})
const keys = () => [...models().map(modelKey), manageKey]
const current = () => {
const value = model.current()
return value ? `${value.provider.id}:${value.id}` : undefined
}
const initialActive = () => {
const selected = current()
const options = keys()
if (selected && options.includes(selected)) return selected
return options[0] ?? ""
}
const activeItem = () =>
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
const afterClose = (callback: () => void) => {
const complete = () => {
if (contentRef?.isConnected) {
requestAnimationFrame(complete)
return
}
requestAnimationFrame(() => requestAnimationFrame(callback))
}
requestAnimationFrame(complete)
}
const setOpen = (open: boolean) => {
if (open) {
restoreTrigger = true
setStore({ open: true, active: initialActive() })
setTimeout(() =>
requestAnimationFrame(() => {
searchRef?.focus()
activeItem()?.scrollIntoView({ block: "nearest" })
}),
)
return
}
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))
}
const manage = () => {
restoreTrigger = false
setOpen(false)
afterClose(() => {
void import("./dialog-manage-models").then((x) => {
dialog.show(() => <x.DialogManageModelsV2 />)
})
})
}
const selectActive = () => {
const item = models().find((item) => modelKey(item) === store.active)
if (item) {
selectModel(item)
return
}
if (store.active === manageKey) manage()
}
const moveActive = (delta: number) => {
const options = keys()
if (options.length === 0) return
const index = options.indexOf(store.active)
const start = index === -1 ? 0 : index
setStore("active", options[(start + delta + options.length) % options.length])
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
}
const setSearch = (value: string) => {
const search = value.trim().toLowerCase()
const first = [...allModels()]
.sort((a, b) => a.name.localeCompare(b.name))
.find(
(item) =>
!search ||
item.name.toLowerCase().includes(search) ||
item.id.toLowerCase().includes(search) ||
item.provider.name.toLowerCase().includes(search),
)
setStore({ search: value, active: first ? modelKey(first) : manageKey })
}
return (
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content
ref={(el: HTMLDivElement) => (contentRef = el)}
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
onPointerDownOutside={() => (restoreTrigger = false)}
onFocusOutside={() => (restoreTrigger = false)}
onCloseAutoFocus={(event) => {
if (!restoreTrigger) event.preventDefault()
}}
>
<div class="flex flex-col p-0.5">
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(el) => (searchRef = el)}
value={store.search}
placeholder={language.t("dialog.model.search.placeholder")}
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"
autocomplete="off"
autocapitalize="off"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Tab") return
event.stopPropagation()
if (event.key === "Escape") {
event.preventDefault()
restoreTrigger = false
setOpen(false)
afterClose(() => props.onClose?.())
return
}
if (event.altKey || event.metaKey) return
if (event.key === "ArrowDown") {
event.preventDefault()
moveActive(1)
return
}
if (event.key === "ArrowUp") {
event.preventDefault()
moveActive(-1)
return
}
if (event.key === "Enter" && !event.isComposing) {
event.preventDefault()
selectActive()
}
}}
/>
<Show when={store.search.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => setSearch("")}
aria-label={language.t("common.clear")}
>
<Icon name="close" size="small" />
</button>
</Show>
</div>
</div>
<div class="h-px bg-v2-border-border-muted" />
<ScrollView data-slot="model-selector-scroll" class="max-h-[220px] min-h-0">
<div class="flex flex-col p-0.5 pt-0">
<Show
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")}
</div>
}
>
<For each={groups()}>
{(group) => (
<MenuV2.Group>
<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()}>
<For each={group.items}>
{(item) => (
<MenuV2.RadioItem
value={modelKey(item)}
data-option-key={modelKey(item)}
data-selected-model={current() === modelKey(item) ? true : undefined}
class="scroll-my-6"
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
onMouseEnter={() => {
setStore("active", modelKey(item))
setTimeout(() => searchRef?.focus())
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
<Show when={item.latest}>
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
</Show>
</MenuV2.RadioItem>
)}
</For>
</MenuV2.RadioGroup>
</MenuV2.Group>
)}
</For>
</Show>
</div>
</ScrollView>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<MenuV2.Item
data-option-key={manageKey}
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === manageKey }}
onMouseEnter={() => {
setStore("active", manageKey)
setTimeout(() => searchRef?.focus())
}}
onSelect={manage}
>
<Icon name="outline-sliders" size="small" />
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
</MenuV2.Item>
</div>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
+100 -43
View File
@@ -35,12 +35,14 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@@ -1480,6 +1482,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model: props.controls.model.selection,
providerID: props.controls.model.selection.current()?.provider?.id,
modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"),
newLayoutDesigns: props.controls.newLayoutDesigns,
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
@@ -1646,6 +1649,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Show when={!providersLoading() && store.mode !== "shell" && showVariantControl()}>
<div
data-component="prompt-variant-control"
class="[&_[data-action=prompt-model-variant]]:![font-weight:440]"
classList={{
"animate-in fade-in": providersShouldFadeIn(),
"hidden group-hover/prompt-input:block group-focus-within/prompt-input:block":
@@ -1662,22 +1666,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
}
>
<Select
size="normal"
options={variants()}
current={props.controls.model.selection.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
<MenuV2
gutter={6}
modal={false}
placement="top-start"
onOpenChange={(open) => setStore("variantOpen", open)}
onSelect={(value) => {
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] justify-start text-v2-text-text-faint"
valueClass="truncate text-[13px] font-[440] leading-5 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
>
<MenuV2.Trigger
as={ButtonV2}
data-action="prompt-model-variant"
variant="ghost-muted"
size="normal"
class="max-w-[160px] justify-start capitalize"
style={control()}
>
<span class="truncate">
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
<Icon name="chevron-down" size="small" />
</span>
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.RadioGroup
value={props.controls.model.selection.variant.current() ?? "default"}
onChange={(value) => {
props.controls.model.selection.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
>
{variants().map((value) => (
<MenuV2.RadioItem value={value} class="capitalize">
{value === "default" ? language.t("common.default") : value}
</MenuV2.RadioItem>
))}
</MenuV2.RadioGroup>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
</div>
</Show>
@@ -2057,6 +2084,7 @@ type ComposerModelControlState = {
model: ReturnType<typeof useLocal>["model"]
providerID?: string
modelName: string
newLayoutDesigns: boolean
style: JSX.CSSProperties | undefined
onClose: () => void
onUnpaidClick: () => void
@@ -2147,36 +2175,65 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
</>
}
>
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
<Show
when={props.state.newLayoutDesigns}
fallback={
<ModelSelectorPopover
model={props.state.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: props.state.style,
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<ModelControlContent state={props.state} />
</ModelSelectorPopover>
}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</ModelSelectorPopover>
<ModelSelectorPopoverV2
model={props.state.model}
triggerAs={ButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "normal",
style: props.state.style,
class: "min-w-0 max-w-[220px] justify-start ![font-weight:440] group",
classList: { "animate-in fade-in": props.state.shouldAnimate },
"data-action": "prompt-model",
}}
onClose={props.state.onClose}
>
<ModelControlContent state={props.state} v2 />
</ModelSelectorPopoverV2>
</Show>
</TooltipV2>
</Show>
</Show>
)
}
function ModelControlContent(props: { state: ComposerModelControlState; v2?: boolean }) {
return (
<>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class={props.v2 ? "-ml-0.5 -mr-1 flex shrink-0" : "-ml-1 shrink-0 flex size-fit"}>
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</>
)
}
@@ -92,11 +92,17 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{props.branch || "main"}</span>
</div>
<Show when={props.branch}>
{(branch) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{branch()}</span>
</div>
</>
)}
</Show>
</>
)
}
@@ -1,9 +1,9 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { Match, Show, Switch, createMemo, type ComponentProps, type JSX } from "solid-js"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
@@ -18,7 +18,16 @@ import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
buttonAppearance?: "default" | "v2"
placement?: TooltipProps["placement"]
placement?: ComponentProps<typeof TooltipV2>["placement"]
}
function ContextTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
return (
<div class="flex min-w-0 items-center gap-4">
<span class="shrink-0 text-v2-text-text-muted">{props.name}</span>
<span class="ml-auto min-w-0 truncate text-right text-v2-text-text-base">{props.value}</span>
</div>
)
}
function openSessionContext(args: {
@@ -113,38 +122,22 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)
const tooltipValue = () => (
<div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">
{getSessionTokenTotal(value())?.toLocaleString(language.intl())}
</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}>
{(ctx) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
)}
</Show>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">{language.t("context.usage.cost")}</span>
</div>
<div class="flex w-[120px] flex-col gap-2">
<ContextTooltipRow name={language.t("context.usage.cost")} value={cost()} />
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
return (
<Show when={params.id}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<TooltipV2 value={tooltipValue()} placement={props.placement ?? "top"} shift={-8}>
<Switch>
<Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<IconButtonV2
type="button"
variant="ghost-muted"
@@ -153,10 +146,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Tooltip>
</Match>
<Match when={true}>
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
</Match>
<Match when={true}>
<Button
type="button"
variant="ghost"
@@ -166,9 +157,9 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
>
{circle()}
</Button>
</Tooltip>
</Match>
</Switch>
</Match>
</Switch>
</TooltipV2>
</Show>
)
}
+113 -66
View File
@@ -5,12 +5,13 @@ 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 } from "@/context/server"
import { projectForSession } from "@/pages/layout/helpers"
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"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -21,6 +22,7 @@ export function TabNavItem(props: {
href: string
server: ServerConnection.Key
session: () => Session | undefined
fallbackTitle?: string
onTitleChange?: (title: string) => void
onTitleChangeFailed?: (title: string) => void
onClose: () => void
@@ -56,6 +58,33 @@ export function TabNavItem(props: {
if (!session) return
return projectForSession(session, serverCtx()?.projects.list() ?? [])
})
const title = createMemo(() => props.session()?.title ?? props.fallbackTitle)
const projectName = createMemo(() => {
const session = props.session()
if (!session) return
return displayName(project() ?? { worktree: session.directory })
})
const previewPath = createMemo(() => {
const session = props.session()
if (!session) return
const home = serverCtx()?.sync.data.path.home
return home ? session.directory.replace(home, "~") : session.directory
})
const branch = createMemo(() => {
const session = props.session()
if (!session) return
return serverCtx()?.sync.child(session.directory, { bootstrap: false })[0].vcs?.branch
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (global.servers.list().length <= 1) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
const [popoverOpen, setPopoverOpen] = createSignal(false)
const previewBlocked = () => !!props.dragging || editing() || !!props.pressed || !props.session()
const measureTitleOverflow = () => {
if (!titleEl || editing()) {
@@ -74,7 +103,7 @@ export function TabNavItem(props: {
}
createEffect(() => {
props.session()?.title
title()
props.forceTruncate
editing()
scheduleTitleOverflow()
@@ -133,9 +162,9 @@ export function TabNavItem(props: {
createEffect(() => {
if (editing()) return
if (!titleEl) return
const title = props.session()?.title
if (title === undefined) return
titleEl.textContent = title
const value = title()
if (value === undefined) return
titleEl.textContent = value
})
const openRename = (event: MouseEvent) => {
@@ -171,7 +200,7 @@ export function TabNavItem(props: {
onCleanup(cleanup)
})
return (
const tab = (
<div
ref={(el) => {
tabRoot = el
@@ -196,26 +225,26 @@ export function TabNavItem(props: {
closeTab(event)
}}
>
<Show when={props.session()}>
{(session) => {
return (
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={title() !== undefined}>
<a
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
draggable={false}
onDragStart={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
>
<Show when={props.session()}>
{(session) => (
<span data-slot="project-avatar-slot">
<SessionTabAvatar
project={project()}
@@ -224,45 +253,45 @@ export function TabNavItem(props: {
activeServer={props.activeServer}
/>
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}}
)}
</Show>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = title() ?? ""
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = props.session()?.title ?? ""
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
</Show>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
@@ -280,6 +309,24 @@ export function TabNavItem(props: {
</div>
</div>
)
return (
<TabPreviewPopover
trigger={tab}
open={popoverOpen() && !previewBlocked()}
onOpenChange={(value) => {
if (value && previewBlocked()) return
setPopoverOpen(value)
}}
data={{
projectName: projectName(),
title: props.session()?.title,
path: previewPath(),
branch: branch(),
serverName: serverLabel(),
}}
/>
)
}
export function DraftTabItem(props: {
@@ -0,0 +1,133 @@
[data-component="session-tab-popover-trigger"] {
display: flex;
width: 100%;
min-width: 0;
}
[data-component="session-tab-popover"] {
z-index: 50;
box-sizing: border-box;
width: 256px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--v2-background-bg-base);
border-radius: 6px;
box-shadow: var(--v2-elevation-floating);
color: var(--v2-text-text-base);
font-variant-numeric: tabular-nums;
user-select: none;
pointer-events: none;
transform-origin: var(--kb-hovercard-content-transform-origin);
/* Entrance: fade in while scaling up from 0.96 to 1.0. */
animation: sessionTabPopoverIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
&:focus-visible,
&:focus-within {
outline: none;
}
/* Exit: subtle fade + scale down, using the same ease-out curve as entry. */
&[data-closed] {
animation: sessionTabPopoverOut 100ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
[data-slot="header"] {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
[data-slot="project"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="title"] {
font-weight: 530;
font-size: 13px;
line-height: 1.2;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
overflow-wrap: anywhere;
}
[data-slot="row"] {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
}
[data-slot="icon"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-muted);
}
[data-slot="detail"] {
flex: 1 0 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
}
[data-slot="server"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@keyframes sessionTabPopoverIn {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes sessionTabPopoverOut {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="session-tab-popover"],
[data-component="session-tab-popover"][data-closed] {
animation: none;
}
}
@@ -0,0 +1,99 @@
import { HoverCard as Kobalte } from "@kobalte/core/hover-card"
import { Show, type JSXElement } from "solid-js"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import "./titlebar-tab-popover.css"
// Initial hover delay before the preview appears, per design.
const OPEN_DELAY = 200
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
const CLOSE_DELAY = 0
// After a preview closes, hovering a neighbouring tab within this window skips
// the open delay — mirrors the tooltip's skipDelayDuration so moving across
// tabs doesn't re-wait the full delay each time.
const SKIP_WINDOW = 300
let lastClosedAt = 0
export interface TabPreviewData {
projectName?: string
title?: string
path?: string
branch?: string
serverName?: string
}
export function TabPreviewPopover(props: {
trigger: JSXElement
open: boolean
onOpenChange: (open: boolean) => void
data: TabPreviewData
}) {
let triggerEl: HTMLDivElement | undefined
// Kobalte reads openDelay lazily when the pointer enters the trigger, so this
// resolves the skip window per-hover.
const resolveOpenDelay = () => (Date.now() - lastClosedAt < SKIP_WINDOW ? 0 : OPEN_DELAY)
const handleOpenChange = (open: boolean) => {
if (!open) lastClosedAt = Date.now()
props.onOpenChange(open)
}
return (
<Kobalte
open={props.open}
onOpenChange={handleOpenChange}
openDelay={resolveOpenDelay()}
closeDelay={CLOSE_DELAY}
// The preview is non-interactive (pointer-events: none), so there is no
// safe area to traverse — leaving the tab hides it immediately.
ignoreSafeArea
placement="bottom-start"
gutter={6}
>
<Kobalte.Trigger ref={triggerEl} as="div" data-component="session-tab-popover-trigger" tabIndex={-1}>
{props.trigger}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
ref={(el) => {
// Portalled content lives outside the themed subtree, so mirror the
// active theme like the v2 tooltip does.
const theme = triggerEl?.closest("[data-theme]")?.getAttribute("data-theme")
if (theme) el.setAttribute("data-theme", theme)
}}
data-component="session-tab-popover"
>
<div data-slot="header">
<Show when={props.data.projectName}>
<span data-slot="project">{props.data.projectName}</span>
</Show>
<Show when={props.data.title}>
<span data-slot="title">{props.data.title}</span>
</Show>
</div>
<Show when={props.data.path}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="folder" />
</span>
<span data-slot="detail">{props.data.path}</span>
</div>
</Show>
<Show when={props.data.branch}>
<div data-slot="row">
<span data-slot="icon">
<IconV2 name="branch" />
</span>
<span data-slot="detail">{props.data.branch}</span>
</div>
</Show>
<Show when={props.data.serverName}>
<div data-slot="server">{props.data.serverName}</div>
</Show>
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
@@ -31,6 +31,7 @@ function SessionTabSlot(props: {
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
const sortable = useSortable({
get id() {
return props.id
@@ -50,6 +51,7 @@ function SessionTabSlot(props: {
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
let prefetched = false
createEffect(() => {
@@ -87,7 +89,7 @@ function SessionTabSlot(props: {
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() }}
classList={{ hidden: !session() && !missingSession() }}
>
<TabNavItem
ref={(el) => {
@@ -96,6 +98,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={missingSession() ? language.t("session.tab.unknown") : undefined}
onTitleChange={(title) => {
const value = session()
const ctx = props.serverCtx()
@@ -1,7 +1,4 @@
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import { sessionUpdatedAt, trimSessions } from "./session-trim"
import type { RootLoadArgs } from "./types"
import { directoryKey } from "./utils"
export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
try {
@@ -26,40 +23,3 @@ export function estimateRootSessionTotal(input: { count: number; limit: number;
if (input.count < input.limit) return input.count
return input.count + 1
}
export function mergeRootSessionLoad(input: {
directory: string
loadedAt: number
listed: Session[]
current: Session[]
limit: number
permission: Record<string, PermissionRequest[]>
}) {
const directory = directoryKey(input.directory)
const currentRoots = input.current.filter(
(session) => !session.parentID && !session.time?.archived && directoryKey(session.directory) === directory,
)
const byID = new Map(
input.listed
.filter((session) => !!session?.id)
.filter((session) => !session.time?.archived)
.map((session) => [session.id, session] as const),
)
// A list response can resolve after live create/update events have already seeded this store.
for (const session of currentRoots) {
const listed = byID.get(session.id)
if (!listed) {
if (sessionUpdatedAt(session) >= input.loadedAt) byID.set(session.id, session)
continue
}
if (sessionUpdatedAt(session) >= input.loadedAt && sessionUpdatedAt(session) > sessionUpdatedAt(listed)) {
byID.set(session.id, session)
}
}
return trimSessions([...byID.values(), ...input.current.filter((session) => !!session.parentID)], {
limit: input.limit,
permission: input.permission,
})
}
+3
View File
@@ -64,6 +64,9 @@ type PlatformBase = {
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Stable platform window identity for window-scoped persistence */
windowID?: string
/** Application-global desktop updater */
updater?: UpdaterPlatform
+1 -72
View File
@@ -1,35 +1,6 @@
import { describe, expect, test } from "bun:test"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import type { Session } from "@opencode-ai/sdk/v2/client"
import {
estimateRootSessionTotal,
loadRootSessionsWithFallback,
mergeRootSessionLoad,
} from "./global-sync/session-load"
function session(input: {
id: string
directory?: string
created?: number
updated?: number
parentID?: string
archived?: number
}) {
return {
id: input.id,
directory: input.directory ?? "/repo",
projectID: "project",
title: input.id,
parentID: input.parentID,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: input.created ?? 1_000,
updated: input.updated ?? input.created ?? 1_000,
archived: input.archived,
},
} as Session
}
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {
@@ -92,48 +63,6 @@ describe("loadRootSessionsWithFallback", () => {
})
})
describe("mergeRootSessionLoad", () => {
test("keeps sessions created while a stale list request was in flight", () => {
const result = mergeRootSessionLoad({
directory: "/repo",
loadedAt: 2_000,
listed: [],
current: [session({ id: "new", created: 2_100 })],
limit: 5,
permission: {},
})
expect(result.map((item) => item.id)).toEqual(["new"])
})
test("keeps newer live session data over stale list responses", () => {
const result = mergeRootSessionLoad({
directory: "/repo",
loadedAt: 2_000,
listed: [session({ id: "same", updated: 1_500 })],
current: [{ ...session({ id: "same", updated: 2_100 }), title: "new title" }],
limit: 5,
permission: {},
})
expect(result).toHaveLength(1)
expect(result[0]?.title).toBe("new title")
})
test("allows authoritative loads to remove old missing roots", () => {
const result = mergeRootSessionLoad({
directory: "/repo",
loadedAt: 2_000,
listed: [],
current: [session({ id: "old", updated: 1_500 })],
limit: 5,
permission: {},
})
expect(result).toEqual([])
})
})
describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+12 -20
View File
@@ -26,11 +26,7 @@ import {
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import {
estimateRootSessionTotal,
loadRootSessionsWithFallback,
mergeRootSessionLoad,
} from "./global-sync/session-load"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
@@ -277,7 +273,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const loadedAt = Date.now()
const promise = queryClient
.fetchQuery({
...queryOptionsApi.sessions(key),
@@ -288,13 +283,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
list: (query) => serverSDK.client.session.list(query),
})
.then((x) => {
const listed = (x.data ?? []).filter((s) => !!s?.id).filter((s) => !s.time?.archived)
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const next = mergeRootSessionLoad({
directory,
loadedAt,
listed,
current: store.session,
const childSessions = store.session.filter((s) => !!s.parentID)
const next = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: session.data.permission,
})
@@ -302,14 +297,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
next.forEach(session.remember)
setStore(
"sessionTotal",
Math.max(
estimateRootSessionTotal({
count: listed.length,
limit: x.limit,
limited: x.limited,
}),
next.filter((item) => !item.parentID).length,
),
estimateRootSessionTotal({
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
)
setStore("session", reconcile(next, { key: "id" }))
})
+2 -2
View File
@@ -51,7 +51,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.global("tabs"),
...Persist.window("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
@@ -62,7 +62,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
const params = useParams()
const navigate = useNavigate()
+24
View File
@@ -388,6 +388,20 @@ export const dict = {
"wsl.onboarding.chooseDistroFirst": "Choose a distro first.",
"wsl.onboarding.loadFailed": "Failed to load WSL state.",
"wsl.onboarding.loading": "Loading...",
"wsl.onboarding.installedDistros": "Installed distros",
"wsl.onboarding.checkAgain": "Check again",
"wsl.onboarding.distroStatus.ready": "Ready",
"wsl.onboarding.distroStatus.checking": "Checking...",
"wsl.onboarding.distroStatus.opencodeMissing": "OpenCode not installed",
"wsl.onboarding.distroStatus.missingTools": "Missing bash, curl",
"wsl.onboarding.distroStatus.unsupported": "Unsupported · Use WSL 2",
"wsl.onboarding.needAnotherDistro": "Need another distro?",
"wsl.onboarding.needAnotherDistroHint": "Install a Linux distribution from the WSL catalog",
"wsl.onboarding.wslNotInstalled.title": "WSL not installed",
"wsl.onboarding.wslNotInstalled.description":
"WSL (Windows Subsystem for Linux) is required before OpenCode can add a WSL server",
"wsl.onboarding.wslUnavailable.title": "WSL unavailable",
"wsl.onboarding.wslUnavailable.description": "OpenCode could not verify WSL on this machine.",
"wsl.onboarding.installWsl": "Install WSL",
"wsl.onboarding.windowsRestartRequired": "Restart Windows to finish installing WSL, then reopen OpenCode.",
"wsl.onboarding.next": "Next",
@@ -397,6 +411,7 @@ export const dict = {
"wsl.onboarding.install": "Install",
"wsl.onboarding.installing": "Installing...",
"wsl.onboarding.installDistro": "Install distro",
"wsl.onboarding.searchDistros": "Search distros",
"wsl.onboarding.wsl2Required": "WSL 2 is required.",
"wsl.onboarding.toolsRequired": "This distro needs bash and curl.",
"wsl.onboarding.openTerminal": "Open terminal",
@@ -604,7 +619,12 @@ export const dict = {
"session.tab.session": "Session",
"session.tab.review": "Review",
"session.tab.context": "Context",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
"session.error.notFound.description": "This tab points to a session that no longer exists on this server.",
"session.error.notFound.closeTab": "Close Tab",
"session.error.serverConnection": "Can't connect to this server",
"session.review.filesChanged": "{{count}} Files Changed",
"session.review.change.one": "Change",
"session.review.change.other": "Changes",
@@ -637,6 +657,10 @@ export const dict = {
"session.todo.expand": "Expand",
"session.todo.progress": "{{done}} of {{total}} todos completed",
"session.question.progress": "{{current}} of {{total}} questions",
"session.question.minimize": "Minimize question",
"session.question.restore": "Restore question",
"session.question.pending.one": "{{count}} pending question",
"session.question.pending.other": "{{count}} pending questions",
"session.followupDock.summary.one": "{{count}} queued message",
"session.followupDock.summary.other": "{{count}} queued messages",
"session.followupDock.sendNow": "Send now",
+57 -1
View File
@@ -194,8 +194,19 @@
timeline-scope: --home-projects-scroll;
}
[data-slot="model-selector-scroll"] {
timeline-scope: --model-selector-scroll;
}
[data-slot="manage-models-scroll"] {
timeline-scope: --manage-models-scroll;
}
[data-slot="home-projects-scroll"]::before,
[data-slot="home-projects-scroll"]::after {
[data-slot="home-projects-scroll"]::after,
[data-slot="model-selector-scroll"]::before,
[data-slot="model-selector-scroll"]::after,
[data-slot="manage-models-scroll"]::before {
content: "";
position: absolute;
left: 0;
@@ -211,11 +222,26 @@
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="model-selector-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-layer-01), transparent);
}
[data-slot="manage-models-scroll"]::before {
top: 0;
background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent);
}
[data-slot="home-projects-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-base), transparent);
}
[data-slot="model-selector-scroll"]::after {
bottom: 0;
background: linear-gradient(to top, var(--v2-background-bg-layer-01), transparent);
}
@supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) {
[data-slot="home-projects-scroll"] .scroll-view__viewport {
scroll-timeline: --home-projects-scroll y;
@@ -233,4 +259,34 @@
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@supports (animation-timeline: --model-selector-scroll) and (timeline-scope: --model-selector-scroll) {
[data-slot="model-selector-scroll"] .scroll-view__viewport {
scroll-timeline: --model-selector-scroll y;
}
[data-slot="model-selector-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --model-selector-scroll;
animation-range: 0 0.1px;
}
[data-slot="model-selector-scroll"]::after {
animation: home-projects-fade-bottom linear both;
animation-timeline: --model-selector-scroll;
animation-range: calc(100% - 1.1px) calc(100% - 1px);
}
}
@supports (animation-timeline: --manage-models-scroll) and (timeline-scope: --manage-models-scroll) {
[data-slot="manage-models-scroll"] .settings-v2-panel {
scroll-timeline: --manage-models-scroll y;
}
[data-slot="manage-models-scroll"]::before {
animation: home-projects-fade-top linear both;
animation-timeline: --manage-models-scroll;
animation-range: 0 0.1px;
}
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
return (
<div
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans"
class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center font-sans"
data-tauri-drag-region
>
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
+2 -2
View File
@@ -671,7 +671,7 @@ function HomeProjectColumn(props: {
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")}
>
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5">
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
@@ -697,7 +697,7 @@ function HomeProjectColumn(props: {
</div>
}
>
<div class="flex min-w-0 flex-col gap-1 pr-3">
<div class="flex min-w-0 flex-col gap-4 pr-3">
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
+315 -90
View File
@@ -3,6 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
import {
batch,
ErrorBoundary,
onCleanup,
Show,
Match,
@@ -10,8 +11,10 @@ import {
createMemo,
createEffect,
createComputed,
createResource,
on,
onMount,
type ParentProps,
untrack,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -19,29 +22,37 @@ import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { debounce } from "@solid-primitives/scheduled"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Select } from "@opencode-ai/ui/select"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useSearchParams } from "@solidjs/router"
import { Navigate, useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { ErrorPage } from "@/pages/error"
import { CommentsProvider, useComments } from "@/context/comments"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { useTabs } from "@/context/tabs"
import { TerminalProvider, useTerminal } from "@/context/terminal"
import { PromptInput } from "@/components/prompt-input"
import { useSettingsCommand } from "@/components/settings-dialog"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
@@ -73,8 +84,8 @@ import { Identifier } from "@/utils/id"
import { diffs as list } from "@/utils/diffs"
import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { formatServerError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, selectSessionLineage, sessionHref } from "@/utils/session-route"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
@@ -91,6 +102,15 @@ const sessionViewState = () => ({
changes: "git" as ChangeMode,
})
function isLocalSessionNotFoundError(error: unknown, sessionID: string) {
return error instanceof Error && error.message === `Session not found: ${sessionID}`
}
function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
if (!sessionID) return false
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
}
async function runPromptRollbackMutation<T, R>(input: {
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
@@ -114,6 +134,204 @@ async function runPromptRollbackMutation<T, R>(input: {
})
}
export function SessionPage() {
return (
<SessionProviders>
<Page />
</SessionProviders>
)
}
export function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<Show when={`${params.serverKey}\0${params.id}`} keyed>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</Show>
)
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
const settings = useSettings()
return (
<ErrorBoundary
fallback={(error) =>
settings.general.newLayoutDesigns() ? (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
) : (
<ErrorPage error={error} />
)
}
>
{props.children}
</ErrorBoundary>
)
}
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
const server = useServer()
const tabs = useTabs()
const displayServer = createMemo(() => {
const key = props.serverKey ?? server.key
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return conn ? serverName(conn) : key
})
const closeTab = () => {
if (!props.sessionID) return
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
}
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
return (
<div class="flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
<div class="flex flex-col items-center gap-2">
<div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
<div class="text-13-regular text-text-weak max-w-md">
{language.t("session.error.notFound.description")}
</div>
</div>
<Show when={props.sessionID}>
{(sessionID) => (
<div class="max-w-full flex flex-col items-center gap-1">
<div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
<code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
{sessionID()}
</code>
</div>
)}
</Show>
<ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
{language.t("session.error.notFound.closeTab")}
</ButtonV2>
</div>
</div>
)
}
return <ErrorPage error={props.error} />
}
function ResolvedTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
const settings = useSettings()
const tabs = useTabs()
const sync = useServerSync()
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const cached = createMemo(() => sync().session.lineage.peek(params.id))
const [resolved] = createResource(
() => {
if (cached()) return
return { id: params.id, sync: sync() }
},
({ id, sync }) => sync.session.lineage.resolve(id),
)
const current = createMemo(() => selectSessionLineage(params.id, cached(), resolved()))
const directory = createMemo(() => current()?.session.directory)
const targetDirectory = () => directory()!
createEffect(() => {
const session = current()
if (!session) return
tabs.addSessionTab({
server: serverKey(),
sessionId: session.root.id,
})
})
return (
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<Show when={directory()}>
<Show
when={settings.general.newLayoutDesigns()}
fallback={<Navigate href={legacySessionHref(directory()!, params.id)} />}
>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
</Show>
</TargetServerScopedProviders>
)
}
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionPage />
</Show>
)
}
function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
return (
<PermissionProvider directory={props.directory}>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</PermissionProvider>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
const sessionID = props.sessionID?.()
if (!notification.ready() || !sessionID) return
if (notification.session.unseenCount(sessionID) === 0) return
notification.session.markViewed(sessionID)
})
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
{props.children}
</div>
)
}
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
return (
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": props.newLayout,
"bg-background-stronger": !props.newLayout,
"rounded-[10px] overflow-hidden": props.newLayout,
"shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
}}
>
{props.children}
</div>
)
}
export default function Page() {
const serverSync = useServerSync()
const layout = useLayout()
@@ -1695,9 +1913,88 @@ export default function Page() {
() => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
)
return (
<div class="relative size-full overflow-hidden flex flex-col">
const sessionErrorFallback = (error: unknown, reset: () => void) => {
createEffect(on(sessionKey, reset, { defer: true }))
return <SessionErrorFallback error={error} sessionID={params.id} />
}
const sessionPanelContent = () => (
<>
{sessionSync() ?? ""}
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
}}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</>
)
return (
<SessionRouteFrame>
<SessionHeader />
<div
class="flex-1 min-h-0 flex flex-col md:flex-row"
@@ -1717,85 +2014,13 @@ export default function Page() {
width: sessionPanelWidth(),
}}
>
<div
classList={{
"flex-1 min-h-0 flex flex-col": true,
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
"rounded-[10px] overflow-hidden": settings.general.newLayoutDesigns(),
"shadow-[var(--v2-elevation-raised)]": settings.general.newLayoutDesigns() && !!params.id,
}}
>
<Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
{mobileTabs(true)}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={params.id && mobileChanges()}>
<div class="relative h-full overflow-hidden">
{reviewContent({
diffStyle: "unified",
classes: {
root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
header: "px-4 !h-16 !pb-4",
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</Match>
<Match when={params.id}>
<Show when={messagesReady() ? params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
actions={actions}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
setScrollRef={setScrollRef}
onScheduleScrollState={scheduleScrollState}
onAutoScrollHandleScroll={autoScroll.handleScroll}
onMarkScrollGesture={markScrollGesture}
hasScrollGesture={hasScrollGesture}
onUserScroll={markUserScroll}
onHistoryScroll={onHistoryScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
shouldAnchorBottom={() =>
!location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
}
centered={centered()}
setContentRef={(el) => {
content = el
autoScroll.contentRef(el)
const root = scroller
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
}}
anchor={anchor}
setRevealMessage={(fn) => {
revealMessage = fn
}}
setScrollToEnd={(fn) => {
scrollToEnd = fn
}}
/>
)}
</Show>
</Match>
<Match when={true}>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>{(_) => composerRegion()}</Show>
<Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
</div>
<SessionPanelFrame newLayout={settings.general.newLayoutDesigns()} raised={!!params.id}>
{settings.general.newLayoutDesigns() ? (
<ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
) : (
sessionPanelContent()
)}
</SessionPanelFrame>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -1832,6 +2057,6 @@ export default function Page() {
</div>
<TerminalPanel />
</div>
</SessionRouteFrame>
)
}
@@ -1,9 +1,10 @@
import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { showToast } from "@/utils/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
@@ -77,9 +78,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
focus: 0,
minimized: false,
optionsHeight: 180,
})
let root: HTMLDivElement | undefined
let optionsRef: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false
@@ -96,11 +100,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const collapse = useSpring(() => (store.minimized ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
const hidden = createMemo(() => Math.max(0, Math.min(1, collapse())))
const optionsOff = createMemo(() => hidden() > 0.98)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
@@ -192,6 +198,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
focus(pickFocus())
})
createEffect(() => {
const el = optionsRef
if (!el) return
const update = () => setStore("optionsHeight", (height) => Math.max(height, el.scrollHeight))
update()
createResizeObserver(el, update)
})
onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
@@ -405,7 +419,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab + 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const back = () => {
@@ -414,163 +428,212 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const tab = store.tab - 1
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const jump = (tab: number) => {
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
focus(pickFocus(tab))
if (!store.minimized) focus(pickFocus(tab))
}
const minimize = () => {
if (sending()) return
setStore("editing", false)
setStore("minimized", true)
}
const restore = () => {
if (sending()) return
setStore("minimized", false)
focus(pickFocus())
}
return (
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<Show when={total() > 1}>
<div data-slot="question-progress">
<For each={questions()}>
{(_, i) => (
<button
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
<div data-component="session-question-dock">
<DockPrompt
kind="question"
ref={(el) => (root = el)}
onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
<div data-slot="question-header-actions">
<Show when={total() > 1}>
<div data-slot="question-progress">
<For each={questions()}>
{(_, i) => (
<button
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
/>
)}
</For>
</div>
</Show>
<button
type="button"
data-component="icon-button"
data-icon="chevron-down"
data-size="normal"
data-variant="ghost"
disabled={sending()}
style={{ transform: `rotate(${hidden() * 180}deg)` }}
onClick={store.minimized ? restore : minimize}
aria-label={language.t(store.minimized ? "session.question.restore" : "session.question.minimize")}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
</Show>
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
</div>
</>
}
>
<div data-slot="question-text" class="overflow-auto">
{question()?.question}
</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={sending()} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</>
}
>
<div
data-slot="question-text"
style={{
display: store.minimized ? "-webkit-box" : undefined,
"-webkit-line-clamp": store.minimized ? "3" : undefined,
"-webkit-box-orient": store.minimized ? "vertical" : undefined,
overflow: store.minimized ? "hidden" : undefined,
}}
>
{question()?.question}
</div>
<Show when={!store.minimized}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
</Show>
<div
ref={(el) => (optionsRef = el)}
data-slot="question-options"
aria-hidden={store.minimized || optionsOff() ? "true" : undefined}
classList={{ "pointer-events-none": hidden() > 0.1 }}
style={{
"max-height": `${Math.max(0, store.optionsHeight * (1 - hidden()))}px`,
opacity: `${Math.max(0, Math.min(1, 1 - hidden()))}`,
visibility: optionsOff() ? "hidden" : "visible",
}}
>
<For each={options()}>
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
>
<form
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
onMouseDown={(e) => {
if (sending()) {
e.preventDefault()
return
}
if (e.target instanceof HTMLTextAreaElement) return
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
if (input instanceof HTMLTextAreaElement) input.focus()
}}
onSubmit={(e) => {
e.preventDefault()
commitCustom()
}}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
<textarea
ref={focusCustom}
data-slot="question-custom-input"
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
setStore("editing", false)
focus(options().length)
return
}
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</div>
</DockPrompt>
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</div>
</DockPrompt>
</div>
)
}
@@ -1375,14 +1375,14 @@ export function MessageTimeline(props: {
<button
type="button"
data-slot="session-title-parent"
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
class="min-w-0 max-w-[40%] truncate px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
onClick={navigateParent}
>
{parentTitle()}
</button>
<span
data-slot="session-title-separator"
class="px-2 text-14-medium text-text-weak"
class="-translate-y-[0.5px] px-1 text-[11px] font-medium text-v2-text-text-faint"
aria-hidden="true"
>
/
@@ -1395,7 +1395,7 @@ export function MessageTimeline(props: {
<h1
data-slot="session-title-child"
classList={{
"text-14-medium text-text-strong truncate": true,
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
@@ -1414,7 +1414,7 @@ export function MessageTimeline(props: {
value={title.draft}
disabled={titleMutation.isPending}
classList={{
"text-14-medium text-text-strong block": true,
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
+23 -1
View File
@@ -16,6 +16,7 @@ type PersistedWithReady<T> = [
type PersistTarget = {
storage?: string
scope?: "window"
legacyStorageNames?: string[]
key: string
legacy?: string[]
@@ -24,6 +25,7 @@ type PersistTarget = {
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const WINDOW_STORAGE = "opencode.window"
const LOCAL_PREFIX = "opencode."
const fallback = new Map<string, boolean>()
@@ -347,6 +349,11 @@ function draftStorage(draftID: string) {
return `opencode.draft.${head}.${sum}.dat`
}
function windowStorage(windowID: string) {
const safe = (windowID || "browser").replace(/[^a-zA-Z0-9._-]/g, "-")
return `${WINDOW_STORAGE}.${safe}.dat`
}
function legacyWorkspaceStorage(dir: string) {
const storage = workspaceStorage(pathKey(dir))
const result = new Set<string>()
@@ -467,6 +474,8 @@ export const PersistTesting = {
localStorageWithPrefix,
migrateLegacy,
normalize,
resolveTarget,
windowStorage,
workspaceStorage,
}
@@ -474,6 +483,9 @@ export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
window(key: string, legacy?: string[]): PersistTarget {
return { scope: "window", key, legacy }
},
draft(draftID: string, key: string, legacy?: string[]): PersistTarget {
return { storage: draftStorage(draftID), key: `draft:${key}`, legacy }
},
@@ -503,6 +515,16 @@ export const Persist = {
},
}
function resolveTarget(target: PersistTarget, platform: Platform): PersistTarget {
if (target.scope !== "window") return target
if (platform.platform === "desktop" && !platform.windowID) return { ...target, storage: GLOBAL_STORAGE }
const windowID = platform.platform === "desktop" ? (platform.windowID ?? "browser") : "browser"
return {
...target,
storage: windowStorage(windowID),
}
}
export function removePersisted(
target: { storage?: string; legacyStorageNames?: string[]; key: string },
platform?: Platform,
@@ -533,7 +555,7 @@ export function persisted<T>(
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
+57
View File
@@ -0,0 +1,57 @@
import { useMutation } from "@tanstack/solid-query"
import { createEffect } from "solid-js"
import type { Accessor } from "solid-js"
import {
addServerProbePlan,
createProbeFailureGate,
runAddableProbePlan,
type AddServerProbePlan,
type WslAddServerView,
} from "./settings-model"
import type { WslInstalledDistro, WslServersPlatform, WslServersState } from "./types"
export function useWslAddServerProbes(input: {
state: Accessor<WslServersState | undefined>
api: WslServersPlatform
view: Accessor<WslAddServerView>
adding: Accessor<boolean>
busy: Accessor<boolean>
selectedDistro: Accessor<string | null>
addableInstalledDistros: Accessor<WslInstalledDistro[]>
onError: (error: unknown) => void
}) {
const gate = createProbeFailureGate()
const probe = useMutation(() => ({
mutationFn: async (command: AddServerProbePlan) => {
if (command.kind === "addable") {
await runAddableProbePlan({ plan: command.plan, api: input.api })
return
}
if (command.plan.action === "probeRuntime") await input.api.probeRuntime()
if (command.plan.action === "refreshDistros") await input.api.refreshDistros()
},
onError: input.onError,
onSettled: (_result, error, command) => {
if (command) gate.settle(command.key, error)
},
}))
createEffect(() => {
if (probe.isPending) return
const command = addServerProbePlan({
state: input.state(),
view: input.view(),
adding: input.adding(),
busy: input.busy(),
selectedDistro: input.selectedDistro(),
addableInstalledDistros: input.addableInstalledDistros(),
})
if (!command || !gate.accepts(command.key)) return
probe.mutate(command)
})
return {
probingAddable: () => probe.isPending && probe.variables?.kind === "addable",
resetProbeFailure: () => gate.reset(),
}
}
+388 -548
View File
@@ -1,20 +1,28 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { RadioGroupV2, RadioItemV2 } from "@opencode-ai/ui/v2/radio-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslAddServerProbes } from "./add-server-probes"
import { useWslServers } from "./context"
import { enterWslOpencodeStep } from "./settings-model"
import { addServerViewModel, type AddServerText } from "./settings-model"
import "./dialog-add-wsl-server.css"
type WslServerStep = "wsl" | "distro" | "opencode"
function isWslRuntimeMissing(error: string | null | undefined) {
if (!error) return true
return /WSL is not installed|not been installed|wsl(?:\.exe)? --install/i.test(error)
}
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
function translate(language: ReturnType<typeof useLanguage>, value: AddServerText) {
if (value.params) return language.t(value.key, value.params)
return language.t(value.key)
}
interface DialogWslServerProps {
@@ -22,210 +30,272 @@ interface DialogWslServerProps {
}
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const controller = useWslAddServerController(props)
const model = controller.model
const primaryButton = () => model().primaryButton
const primaryButtonStyle = () => {
const width = primaryButton().width
if (!width) return undefined
return { width }
}
return (
<Show
when={!controller.wslServers.isPending && !controller.wslServers.isError}
fallback={
<Dialog fit class="settings-v2-wsl-dialog">
<Show
when={!controller.wslServers.isError}
fallback={<div class="settings-v2-wsl-loading">{controller.loadError()}</div>}
>
<div class="settings-v2-wsl-loading">
<LoaderV2 />
</div>
</Show>
</Dialog>
}
>
<Show
when={model().runtimeState === "ready"}
fallback={
<Show
when={model().runtimeState === "checking" || model().runtimeState === "loading"}
fallback={
<DialogWslSetup
state={model().runtimeState}
error={controller.runtimeError()}
installable={isWslRuntimeMissing(controller.runtimeError())}
busy={model().busy}
onInstall={controller.installWsl}
/>
}
>
<Dialog fit class="settings-v2-wsl-dialog">
<div class="settings-v2-wsl-loading">
<LoaderV2 />
</div>
</Dialog>
</Show>
}
>
<Dialog fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>
{controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")}
</DialogTitle>
</DialogHeader>
<DividerV2 />
<Show
when={controller.view() === "main"}
fallback={
<>
<DialogBody class="settings-v2-wsl-dialog-body settings-v2-wsl-catalog-picker">
<TextInputV2
class="settings-v2-wsl-catalog-search"
appearance="large"
placeholder={language.t("wsl.onboarding.searchDistros")}
value={controller.catalogSearch()}
disabled={model().busy}
onInput={(event) => controller.setCatalogSearch(event.currentTarget.value)}
/>
<div class="settings-v2-wsl-catalog-list">
<RadioGroupV2
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installDistro")}
value={model().catalogTarget ?? undefined}
onChange={controller.setCatalogTarget}
disabled={model().busy}
>
<For each={model().filteredInstallableDistros}>
{(item) => (
<RadioItemV2
class="settings-v2-wsl-distro-row settings-v2-wsl-catalog-row"
value={item.name}
disabled={model().busy}
label={<span class="settings-v2-wsl-distro-label">{item.label}</span>}
/>
)}
</For>
</RadioGroupV2>
</div>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={model().busy} onClick={controller.closeCatalog}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
variant={model().installingCatalogDistro ? "loading" : "contrast"}
disabled={!model().installingCatalogDistro && (model().busy || !model().catalogTarget)}
style={{ width: "99px" }}
onClick={controller.installCatalogDistro}
>
<Show when={model().installingCatalogDistro} fallback={language.t("wsl.onboarding.installDistro")}>
<LoaderV2 />
</Show>
</ButtonV2>
</DialogFooter>
</>
}
>
<DialogBody class="settings-v2-wsl-dialog-body">
<div class="settings-v2-wsl-section-header">
<span class="settings-v2-wsl-section-title">{language.t("wsl.onboarding.installedDistros")}</span>
<ButtonV2
variant="ghost-muted"
size="small"
disabled={model().busy}
onClick={controller.refreshDistros}
>
{language.t("wsl.onboarding.checkAgain")}
</ButtonV2>
</div>
<Show
when={model().addableInstalledDistros.length > 0}
fallback={
<div class="settings-v2-wsl-distro-list">
<div class="settings-v2-wsl-distro-empty">
{model().visibleInstalledDistros.length
? language.t("wsl.onboarding.allDistrosAdded")
: language.t("wsl.onboarding.noDistros")}
</div>
</div>
}
>
<div class="settings-v2-wsl-distro-list">
<RadioGroupV2
hideLabel
class="settings-v2-wsl-distro-group"
label={language.t("wsl.onboarding.installedDistros")}
value={model().selectedDistro ?? undefined}
onChange={controller.setSelectedDistro}
disabled={model().busy}
>
<For each={model().addableInstalledDistros}>
{(item) => {
const status = () => model().distroStatuses[item.name] ?? null
return (
<RadioItemV2
class={`settings-v2-wsl-distro-row${item.version === 1 ? " settings-v2-wsl-distro-row--unsupported" : ""}`}
value={item.name}
disabled={item.version === 1 || model().busy}
label={<span class="settings-v2-wsl-distro-label">{item.name}</span>}
description={
<Show when={status()}>
{(value) => (
<span class="settings-v2-wsl-distro-status" data-tone={value().tone}>
{translate(language, value().label)}
</span>
)}
</Show>
}
/>
)
}}
</For>
</RadioGroupV2>
</div>
</Show>
<Show when={model().installableDistros.length > 0}>
<button
type="button"
class="settings-v2-wsl-catalog-card"
disabled={model().busy}
onClick={controller.openCatalog}
>
<span class="settings-v2-wsl-catalog-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.5564 10.4443V13.5554H4.22309C3.24087 13.5554 2.44531 13.5554 2.44531 13.5554V10.4443M11.112 5.99989L8.00087 9.111L4.88976 5.99989M8.00087 9.111L8.00087 2.44434"
stroke="currentColor"
/>
</svg>
</span>
<span class="settings-v2-wsl-catalog-copy">
<span class="settings-v2-wsl-catalog-title">{language.t("wsl.onboarding.needAnotherDistro")}</span>
<span class="settings-v2-wsl-catalog-description">
{language.t("wsl.onboarding.needAnotherDistroHint")}
</span>
</span>
<span class="settings-v2-wsl-catalog-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="currentColor" />
</svg>
</span>
</button>
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.adding()} onClick={controller.close}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
variant={primaryButton().loading ? "loading" : primaryButton().variant}
disabled={!primaryButton().loading && primaryButton().disabled}
style={primaryButtonStyle()}
onClick={controller.runPrimary}
>
<Show when={primaryButton().loading} fallback={translate(language, primaryButton().label)}>
<LoaderV2 />
</Show>
</ButtonV2>
</DialogFooter>
</Show>
</Dialog>
</Show>
</Show>
)
}
function useWslAddServerController(props: DialogWslServerProps) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
view: "main" as "main" | "catalog",
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
catalogSearch: "",
catalogTarget: null as string | null,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
const viewModel = (probingAddable: boolean) =>
addServerViewModel({
state: current(),
view: store.view,
selectedDistro: store.selectedDistro,
catalogSearch: store.catalogSearch,
catalogTarget: store.catalogTarget,
adding: store.adding,
probingAddable,
})
const baseModel = createMemo(() => viewModel(false))
const probes = useWslAddServerProbes({
state: current,
api,
view: () => store.view,
adding: () => store.adding,
busy: () => baseModel().busy,
selectedDistro: () => baseModel().selectedDistro,
addableInstalledDistros: () => baseModel().addableInstalledDistros,
onError: (error) => requestError(language, error),
})
const busy = createMemo(() => !!current()?.job || store.adding)
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const selectedDistro = createMemo(() => {
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
return store.selectedDistro
}
const distro = defaultInstalledDistro()
if (distro && !existingServerDistros().has(distro.name)) return distro.name
return null
})
const selectedProbe = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const opencodeCheck = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = selectedDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
return language.t("wsl.onboarding.openDistroOnce", { distro })
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const model = createMemo(() => viewModel(probes.probingAddable()))
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = selectedDistro()
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
return state.runtime?.error ?? language.t("wsl.onboarding.required")
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingDistros")
const distro = selectedDistro()
if (state.job?.kind === "install-distro")
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
if (state.job?.kind === "probe-distro")
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady())
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
return language.t("wsl.onboarding.pickDistro")
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingOpencode")
const distro = selectedDistro()
if (state.job?.kind === "install-opencode") {
return distro
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
: language.t("wsl.onboarding.updatingOpencode")
}
if (state.job?.kind === "probe-opencode") {
return distro
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
: language.t("wsl.onboarding.checkingOpencode")
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
: language.t("wsl.onboarding.updateOpencode")
}
if (opencodeReady()) {
return distro
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
: language.t("wsl.onboarding.opencodeReady")
}
return distro
? language.t("wsl.onboarding.installOpencodeIn", { distro })
: language.t("wsl.onboarding.chooseDistroFirst")
})
const openCatalog = () => {
const first = model().installableDistros[0]
setStore({
view: "catalog",
catalogSearch: "",
catalogTarget: first?.name ?? null,
})
}
const run = async (action: () => Promise<unknown>) => {
try {
@@ -235,26 +305,43 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = selectedDistro()
if (!distro) return
void run(() => action(distro))
const refreshDistros = () => {
void run(async () => {
probes.resetProbeFailure()
await api.refreshDistros()
})
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
const installDistro = (name: string) => {
void run(async () => {
probes.resetProbeFailure()
await api.installDistro(name)
setStore("view", "main")
})
}
const openOpencodeStep = () => {
const distro = selectedDistro()
if (!distro) return
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
const installCatalogDistro = () => {
if (model().installingCatalogDistro) return
const name = model().catalogTarget
if (!name) return
installDistro(name)
}
const finish = async () => {
const distro = selectedDistro()
if (!distro) return
const closeCatalog = () => {
probes.resetProbeFailure()
setStore({ view: "main", catalogSearch: "", catalogTarget: null })
}
const runPrimary = async () => {
const button = model().primaryButton
if (button.loading) return
const distro = model().selectedDistro
const action = button.action
if (!distro || !action) return
if (action === "install-opencode") {
await run(() => api.installOpencode(distro))
return
}
setStore("adding", true)
try {
await api.addServer(distro)
@@ -270,346 +357,99 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) {
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title:
step === "wsl"
? language.t("wsl.server.label")
: step === "distro"
? language.t("wsl.onboarding.step.distro")
: language.t("wsl.onboarding.step.opencode"),
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const loadError = () => {
const error = wslServers.error
if (!error) return language.t("wsl.onboarding.loadFailed")
return error instanceof Error ? error.message : String(error)
})
}
return {
wslServers,
model,
loadError,
runtimeError: () => current()?.runtime?.error ?? null,
view: () => store.view,
catalogSearch: () => store.catalogSearch,
adding: () => store.adding,
setCatalogSearch: (value: string) => setStore("catalogSearch", value),
setCatalogTarget: (value: string) => setStore("catalogTarget", value),
setSelectedDistro: (value: string) => setStore("selectedDistro", value),
openCatalog,
closeCatalog,
refreshDistros,
installCatalogDistro,
installWsl: () => void run(() => api.installWsl()),
runPrimary: () => void runPrimary(),
close: () => dialog.close(),
}
}
function DialogWslSetup(props: {
state: string
error: string | null
installable: boolean
busy: boolean
onInstall: () => void
}) {
const language = useLanguage()
const dialog = useDialog()
const title = () =>
props.state === "pendingRestart"
? language.t("wsl.onboarding.restartRequired")
: props.installable
? language.t("wsl.onboarding.wslNotInstalled.title")
: language.t("wsl.onboarding.wslUnavailable.title")
const description = () => {
if (props.state === "pendingRestart") return language.t("wsl.onboarding.windowsRestartRequired")
if (!props.installable) return language.t("wsl.onboarding.wslUnavailable.description")
return language.t("wsl.onboarding.wslNotInstalled.description")
}
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show
when={!wslServers.isPending}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.loading")}</div>}
>
<Show
when={!wslServers.isError}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}
>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
{language.t("wsl.onboarding.installWsl")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3">
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.windowsRestartRequired")}
</div>
</div>
</Show>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !wslReady()}
onClick={() => setStore("step", "distro")}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.distro")}</div>
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? language.t("wsl.onboarding.allDistrosAdded")
: current()?.runtime?.available
? language.t("wsl.onboarding.noDistros")
: language.t("wsl.onboarding.checkingDistros")}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro()
? language.t("wsl.onboarding.installing")
: language.t("wsl.onboarding.install")}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label={language.t("wsl.onboarding.installDistro")}
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => installTarget()?.name === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.wsl2Required")}
</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.toolsRequired")}
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
{language.t("wsl.onboarding.openTerminal")}
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedDistro() || !distroReady()}
onClick={openOpencodeStep}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">{language.t("wsl.onboarding.step.opencode")}</div>
<div class="flex items-center gap-2">
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath
? language.t("wsl.onboarding.updateOpencode")
: language.t("wsl.onboarding.installOpencode")}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.path", {
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
})}
</div>
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.version", {
version: check().version ?? language.t("wsl.onboarding.unknown"),
})}
<Show when={check().expectedVersion}>
{(expected) => (
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
)}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.versionMismatch")}
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
</Button>
</div>
<Dialog fit class="settings-v2-wsl-not-installed-dialog">
<div class="settings-v2-wsl-not-installed-content">
<div class="settings-v2-wsl-not-installed-message">
<svg
class="settings-v2-wsl-not-installed-icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g clip-path="url(#settings-v2-wsl-warning-clip)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 -0.00244141L23.6926 20.2498H0.308594L12 -0.00244141ZM12.7954 6.32932C12.5844 6.11834 12.2982 5.99982 11.9999 5.99982C11.7015 5.99982 11.4154 6.11834 11.2044 6.32932C10.9934 6.5403 10.8749 6.82645 10.8749 7.12482V11.6248C10.8749 11.9232 10.9934 12.2093 11.2044 12.4203C11.4154 12.6313 11.7015 12.7498 11.9999 12.7498C12.2982 12.7498 12.5844 12.6313 12.7954 12.4203C13.0064 12.2093 13.1249 11.9232 13.1249 11.6248V7.12482C13.1249 6.82645 13.0064 6.5403 12.7954 6.32932ZM13.0605 17.5605C12.7792 17.8418 12.3977 17.9998 11.9999 17.9998C11.6021 17.9998 11.2205 17.8418 10.9392 17.5605C10.6579 17.2792 10.4999 16.8976 10.4999 16.4998C10.4999 16.102 10.6579 15.7205 10.9392 15.4392C11.2205 15.1579 11.6021 14.9998 11.9999 14.9998C12.3977 14.9998 12.7792 15.1579 13.0605 15.4392C13.3418 15.7205 13.4999 16.102 13.4999 16.4998C13.4999 16.8976 13.3418 17.2792 13.0605 17.5605Z"
fill="#DBDBDB"
/>
</g>
<defs>
<clipPath id="settings-v2-wsl-warning-clip">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
<h2 class="settings-v2-wsl-not-installed-title">{title()}</h2>
<p class="settings-v2-wsl-not-installed-description">{description()}</p>
<Show when={!props.installable && props.error}>
<p class="settings-v2-wsl-unavailable-error">{props.error}</p>
</Show>
</div>
<Show when={props.state === "unavailable" && props.installable}>
<ButtonV2 variant="neutral" disabled={props.busy} onClick={props.onInstall}>
{language.t("wsl.onboarding.installWsl")}
</ButtonV2>
</Show>
</Show>
</div>
<Show when={props.state !== "unavailable"}>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("common.close")}
</ButtonV2>
</Show>
</div>
</Dialog>
)
}
@@ -0,0 +1,392 @@
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
height: auto;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-content"] {
align-items: stretch;
width: 100%;
overflow: hidden;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 16px 0;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-body"] {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
padding: 0;
gap: 0;
}
.settings-v2-wsl-dialog-body {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
min-width: 0;
align-self: stretch;
box-sizing: border-box;
padding: 16px 16px 1px;
}
[data-component="dialog-v2"].settings-v2-wsl-dialog [data-slot="dialog-footer"] {
padding: 0 16px 24px;
margin-top: 8px;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
min-height: 264px;
height: auto;
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-overlay);
border-radius: 10px;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-content"] {
align-items: stretch;
min-height: 264px;
border-radius: 10px;
overflow: hidden;
}
[data-component="dialog-v2"].settings-v2-wsl-not-installed-dialog [data-slot="dialog-body"] {
display: flex;
flex: 1;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: 0;
}
.settings-v2-wsl-not-installed-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 56px 0;
gap: 24px;
text-align: center;
}
.settings-v2-wsl-not-installed-message {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 8px;
}
.settings-v2-wsl-not-installed-icon {
flex: none;
flex-shrink: 0;
}
.settings-v2-wsl-not-installed-title {
display: flex;
flex: none;
align-items: center;
justify-content: center;
margin: 0;
font-style: normal;
font-weight: 530;
font-size: 15px;
line-height: 20px;
letter-spacing: -0.13px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-not-installed-description {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 100%;
max-width: 300px;
margin: 0;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 20px;
text-align: center;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-unavailable-error {
width: 100%;
max-width: 360px;
margin: 4px 0 0;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 18px;
text-align: center;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-faint);
overflow-wrap: anywhere;
}
.settings-v2-wsl-section-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex: none;
align-self: stretch;
gap: 12px;
height: 24px;
padding: 0 0 0 9px;
}
.settings-v2-wsl-section-title {
flex: none;
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-distro-list {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: none;
align-self: stretch;
width: 100%;
height: fit-content;
max-height: 220px;
padding: 0;
border-radius: 8px;
background: var(--v2-background-bg-base);
box-shadow: var(--v2-elevation-raised);
overflow-x: hidden;
overflow-y: auto;
}
.settings-v2-wsl-distro-group {
width: 100%;
}
.settings-v2-wsl-distro-group [data-slot="radio-v2-items"] {
width: 100%;
gap: 0;
}
.settings-v2-wsl-distro-row {
box-sizing: border-box;
width: 100%;
min-height: 62px;
padding: 14px 14px 14px 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-wsl-distro-row:last-child {
border-bottom: none;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"] {
position: relative;
display: flex;
flex: 1;
width: 100%;
min-width: 0;
align-self: stretch;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-label"]::before {
content: "";
position: absolute;
inset: -14px -14px -14px -44px;
}
.settings-v2-wsl-distro-row [data-slot="radio-v2-item-text"] {
width: 100%;
}
.settings-v2-wsl-distro-label {
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: inherit;
}
.settings-v2-wsl-distro-status {
flex: none;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 12px;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
}
.settings-v2-wsl-distro-status[data-tone="success"] {
color: var(--v2-state-fg-success);
}
.settings-v2-wsl-distro-status[data-tone="warning"] {
color: var(--v2-state-fg-warning);
}
.settings-v2-wsl-distro-status[data-tone="muted"] {
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-distro-row:where([data-disabled]) .settings-v2-wsl-distro-status {
color: inherit;
}
.settings-v2-wsl-distro-empty {
padding: 16px;
font-size: 13px;
font-weight: 440;
line-height: 1.4;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-catalog-card {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: flex-start;
flex: none;
align-self: stretch;
gap: 12px;
width: 100%;
min-height: 64px;
padding: 14px 14px 16px 16px;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: transparent;
cursor: pointer;
text-align: left;
}
.settings-v2-wsl-catalog-card:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
.settings-v2-wsl-catalog-card:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.settings-v2-wsl-catalog-icon {
display: flex;
flex: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-base);
}
.settings-v2-wsl-catalog-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 6px;
}
.settings-v2-wsl-catalog-title {
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 16px;
letter-spacing: -0.04px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-base);
}
.settings-v2-wsl-catalog-description {
user-select: none;
font-style: normal;
font-weight: 440;
font-size: 12px;
line-height: 12px;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
color: var(--v2-text-text-muted);
}
.settings-v2-wsl-catalog-chevron {
display: flex;
flex: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--v2-icon-icon-muted);
}
.settings-v2-wsl-catalog-list {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: none;
align-self: stretch;
width: 100%;
height: fit-content;
max-height: 220px;
padding: 0;
border-radius: 8px;
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
overflow-x: hidden;
overflow-y: auto;
}
.settings-v2-wsl-catalog-picker {
gap: 12px;
}
.settings-v2-wsl-catalog-search {
width: 100%;
}
.settings-v2-wsl-catalog-row {
min-height: 48px;
}
.settings-v2-wsl-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 24px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
+183 -9
View File
@@ -1,5 +1,31 @@
import { describe, expect, test } from "bun:test"
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
import {
addableProbePlan,
addServerProbePlan,
addServerViewModel,
autoProbePlan,
createProbeFailureGate,
runAddableProbePlan,
wslOpencodeAction,
wslRuntimeRetryable,
} from "./settings-model"
import type { WslServersState } from "./types"
const readyWslState = readyState()
function readyState(input: Partial<WslServersState> = {}): WslServersState {
return {
runtime: { available: true, version: "2.4.13.0", error: null },
installed: [],
online: [],
distroProbes: {},
opencodeChecks: {},
pendingRestart: false,
servers: [],
job: null,
...input,
}
}
describe("WSL server settings presentation", () => {
test("retries only settled unsuccessful runtimes", () => {
@@ -45,13 +71,161 @@ describe("WSL server settings presentation", () => {
).toBeUndefined()
})
test("probes the selected distro before entering the OpenCode step", async () => {
const calls: string[] = []
await enterWslOpencodeStep(
"Debian",
async (distro) => calls.push(distro),
(step) => calls.push(step),
)
expect(calls).toEqual(["Debian", "opencode"])
test("plans addable distro probes with the selected distro first", () => {
const plan = addableProbePlan({
state: readyWslState,
view: "main",
adding: false,
selectedDistro: "Ubuntu",
addableInstalledDistros: [
{ name: "Debian", version: 2, isDefault: true },
{ name: "Ubuntu", version: 2, isDefault: false },
],
})
expect(plan?.key).toBe("distro:Ubuntu|distro:Debian")
expect(plan?.distros).toEqual(["Ubuntu", "Debian"])
})
test("plans bootstrap probes for missing runtime and initial distro lists", () => {
expect(autoProbePlan({ state: undefined, busy: false })).toBeUndefined()
expect(autoProbePlan({ state: { ...readyWslState, runtime: null }, busy: false })).toEqual({
key: "runtime",
action: "probeRuntime",
})
expect(autoProbePlan({ state: readyWslState, busy: false })).toEqual({
key: "distros",
action: "refreshDistros",
})
})
test("uses one command plan for bootstrap before addable distro probing", () => {
expect(
addServerProbePlan({
state: { ...readyWslState, runtime: null },
view: "main",
adding: false,
busy: false,
selectedDistro: null,
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
}),
).toEqual({ kind: "auto", key: "auto:runtime", plan: { key: "runtime", action: "probeRuntime" } })
expect(
addServerProbePlan({
state: {
...readyWslState,
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
},
view: "main",
adding: false,
busy: false,
selectedDistro: "Debian",
addableInstalledDistros: [{ name: "Debian", version: 2, isDefault: true }],
}),
).toEqual({ kind: "addable", key: "addable:distro:Debian", plan: { key: "distro:Debian", distros: ["Debian"] } })
})
test("does not accept the same failed probe command until reset", () => {
const gate = createProbeFailureGate()
expect(gate.accepts("addable:distro:Debian")).toBe(true)
gate.settle("addable:distro:Debian", new Error("wsl failed"))
expect(gate.accepts("addable:distro:Debian")).toBe(false)
expect(gate.accepts("addable:distro:Ubuntu")).toBe(true)
gate.reset()
expect(gate.accepts("addable:distro:Debian")).toBe(true)
})
test("keeps default distro selection stable while probes resolve", () => {
const model = addServerViewModel({
state: {
...readyWslState,
installed: [
{ name: "Debian", version: 2, isDefault: true },
{ name: "Ubuntu", version: 2, isDefault: false },
],
online: [{ name: "Alpine", label: "Alpine Linux" }],
distroProbes: {
Ubuntu: { name: "Ubuntu", canExecute: true, hasBash: true, hasCurl: true, error: null },
},
},
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: false,
})
expect(model.selectedDistro).toBe("Debian")
})
test("keeps the dialog busy across serial addable probe job gaps", () => {
const model = addServerViewModel({
state: readyState({
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
}),
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: true,
})
expect(model.busy).toBe(true)
})
test("does not report ready when OpenCode is present but cannot run", () => {
const model = addServerViewModel({
state: {
...readyWslState,
installed: [{ name: "Debian", version: 2, isDefault: true }],
online: [{ name: "Ubuntu", label: "Ubuntu" }],
distroProbes: {
Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null },
},
opencodeChecks: {
Debian: {
distro: "Debian",
resolvedPath: "/home/me/.opencode/bin/opencode",
version: null,
expectedVersion: "1.2.3",
matchesDesktop: null,
error: "opencode is installed but could not run",
},
},
},
view: "main",
selectedDistro: null,
catalogSearch: "",
catalogTarget: null,
adding: false,
probingAddable: false,
})
expect(model.distroStatuses.Debian).toEqual({
label: { key: "wsl.onboarding.installOpencode" },
tone: "warning",
})
expect(model.primaryButton.action).toBe("install-opencode")
})
test("delegates addable probe plans to one batch command", async () => {
const calls: string[][] = []
await runAddableProbePlan({
plan: { key: "distro:Debian|distro:Ubuntu", distros: ["Debian", "Ubuntu"] },
api: {
probeAddable: async (distros) => {
calls.push(distros)
},
},
})
expect(calls).toEqual([["Debian", "Ubuntu"]])
})
})
+332 -10
View File
@@ -1,19 +1,341 @@
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
import fuzzysort from "fuzzysort"
import type {
WslInstalledDistro,
WslOnlineDistro,
WslOpencodeCheck,
WslServersPlatform,
WslServerRuntime,
WslServersState,
} from "./types"
export type AddServerText = {
key: string
params?: Record<string, string>
}
export type DistroStatusTone = "success" | "warning" | "muted"
export type DistroStatus = {
label: AddServerText
tone: DistroStatusTone
}
export type AddServerPrimaryButton = {
variant: "neutral" | "contrast"
label: AddServerText
disabled: boolean
action: "install-opencode" | "add" | null
loading: boolean
width: string | null
}
export type AddServerRuntimeState = "loading" | "pendingRestart" | "checking" | "unavailable" | "ready"
export type AddableProbePlan = {
key: string
distros: string[]
}
export type AutoProbePlan = { key: "runtime"; action: "probeRuntime" } | { key: "distros"; action: "refreshDistros" }
export type AddServerProbePlan =
| { kind: "auto"; key: string; plan: AutoProbePlan }
| { kind: "addable"; key: string; plan: AddableProbePlan }
export type WslAddServerView = "main" | "catalog"
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
runtime.kind === "failed" || runtime.kind === "stopped"
export async function enterWslOpencodeStep(
distro: string,
probe: (distro: string) => Promise<unknown>,
select: (step: "opencode") => void,
) {
await probe(distro)
select("opencode")
}
export function wslOpencodeAction(check?: WslOpencodeCheck) {
if (!check) return
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
}
export function wslDistroReady(state: WslServersState | undefined, name: string) {
const installed = state?.installed.find((item) => item.name === name)
const probe = state?.distroProbes[name]
if (!probe || !installed) return false
if (installed.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
}
export function addServerViewModel(input: {
state: WslServersState | undefined
view: WslAddServerView
selectedDistro: string | null
catalogSearch: string
catalogTarget: string | null
adding: boolean
probingAddable: boolean
}) {
const state = input.state
const visibleInstalledDistros = (state?.installed ?? []).filter((item) => !isHiddenDistro(item.name))
const visibleOnlineDistros = (state?.online ?? []).filter((item) => !isHiddenDistro(item.name))
const existingServerDistros = new Set((state?.servers ?? []).map((item) => item.config.distro))
const addableInstalledDistros = visibleInstalledDistros.filter((item) => !existingServerDistros.has(item.name))
const selectedDistro = addServerSelectedDistro(input.selectedDistro, visibleInstalledDistros, addableInstalledDistros)
const opencodeCheck = selectedDistro ? (state?.opencodeChecks[selectedDistro] ?? null) : null
const installableDistros = addServerInstallableDistros(visibleInstalledDistros, visibleOnlineDistros)
const filteredInstallableDistros = addServerFilteredInstallableDistros(installableDistros, input.catalogSearch)
const catalogTarget = addServerCatalogTarget(input.catalogTarget, filteredInstallableDistros)
const busy = !!state?.job || input.adding || input.probingAddable
return {
busy,
runtimeState: addServerRuntimeState(state),
visibleInstalledDistros,
visibleOnlineDistros,
addableInstalledDistros,
selectedDistro,
opencodeCheck,
wslReady: !!state?.runtime?.available && !state?.pendingRestart,
distroStatuses: Object.fromEntries(
addableInstalledDistros.flatMap((item) => {
const status = addServerDistroStatus({ state, name: item.name, probingAddable: input.probingAddable })
if (!status) return []
return [[item.name, status]]
}),
) as Record<string, DistroStatus | undefined>,
primaryButton: addServerPrimaryButton({
state,
selectedDistro,
opencodeCheck,
adding: input.adding,
probingAddable: input.probingAddable,
}),
installableDistros,
filteredInstallableDistros,
catalogTarget,
installingCatalogDistro: state?.job?.kind === "install-distro",
}
}
function addServerSelectedDistro(
selected: string | null,
visibleInstalledDistros: WslInstalledDistro[],
addableInstalledDistros: WslInstalledDistro[],
) {
if (selected && addableInstalledDistros.some((item) => item.name === selected && item.version !== 1)) return selected
const defaultDistro = visibleInstalledDistros.find((item) => item.isDefault)
if (
defaultDistro &&
defaultDistro.version !== 1 &&
addableInstalledDistros.some((item) => item.name === defaultDistro.name)
) {
return defaultDistro.name
}
return addableInstalledDistros.find((item) => item.version !== 1)?.name ?? null
}
function addServerRuntimeState(state: WslServersState | undefined): AddServerRuntimeState {
if (!state) return "loading"
if (state.pendingRestart) return "pendingRestart"
if (!state.runtime) return "checking"
if (!state.runtime.available) return "unavailable"
return "ready"
}
function addServerDistroStatus(input: {
state: WslServersState | undefined
name: string
probingAddable: boolean
}): DistroStatus | undefined {
const installed = input.state?.installed.find((item) => item.name === input.name)
if (installed?.version === 1) return { label: { key: "wsl.onboarding.distroStatus.unsupported" }, tone: "muted" }
const job = input.state?.job
const probe = input.state?.distroProbes[input.name]
if (!probe) {
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
return checkingStatus()
}
return
}
if (!probe.canExecute) {
if (!installed) {
return { label: { key: "wsl.onboarding.distroNotInstalled", params: { distro: input.name } }, tone: "warning" }
}
return { label: { key: "wsl.onboarding.openDistroOnce", params: { distro: input.name } }, tone: "warning" }
}
if (!probe.hasBash || !probe.hasCurl) {
return { label: { key: "wsl.onboarding.distroStatus.missingTools" }, tone: "warning" }
}
const check = input.state?.opencodeChecks[input.name]
if (!check) {
if (input.probingAddable || (job?.kind === "probe-addable" && job.distros.includes(input.name))) {
return checkingStatus()
}
return
}
if (check.matchesDesktop === false) return { label: { key: "wsl.onboarding.updateOpencode" }, tone: "warning" }
if (!check.resolvedPath) return { label: { key: "wsl.onboarding.distroStatus.opencodeMissing" }, tone: "warning" }
if (check.error) return { label: { key: "wsl.onboarding.installOpencode" }, tone: "warning" }
return { label: { key: "wsl.onboarding.distroStatus.ready" }, tone: "success" }
}
function checkingStatus(): DistroStatus {
return { label: { key: "wsl.onboarding.distroStatus.checking" }, tone: "muted" }
}
function addServerPrimaryButton(input: {
state: WslServersState | undefined
selectedDistro: string | null
opencodeCheck: WslOpencodeCheck | null
adding: boolean
probingAddable: boolean
}): AddServerPrimaryButton {
const ready = !!input.selectedDistro && wslDistroReady(input.state, input.selectedDistro)
const probingSelected = input.probingAddable && !addServerSelectedDistroSettled(input.state, input.selectedDistro)
const probingOpencode =
probingSelected ||
(ready &&
(!input.opencodeCheck ||
(!!input.selectedDistro &&
input.state?.job?.kind === "probe-addable" &&
input.state.job.distros.includes(input.selectedDistro))))
const installingOpencode =
input.state?.job?.kind === "install-opencode" && input.state.job.distro === input.selectedDistro
if (!ready || probingOpencode) {
return {
variant: "contrast",
label: probingSelected ? { key: "wsl.onboarding.distroStatus.checking" } : { key: "wsl.server.add" },
disabled: true,
action: null,
loading: probingSelected,
width: null,
}
}
if (!addServerOpencodeReady(input.opencodeCheck)) {
const update = !!input.opencodeCheck?.resolvedPath && input.opencodeCheck.matchesDesktop === false
return {
variant: "neutral",
label: installingOpencode
? { key: "wsl.onboarding.updatingOpencode" }
: update
? { key: "wsl.onboarding.updateOpencode" }
: { key: "wsl.onboarding.installOpencode" },
disabled: !!input.state?.job || input.adding,
action: "install-opencode",
loading: installingOpencode,
width: update ? "138px" : "129px",
}
}
return {
variant: "contrast",
label: input.adding ? { key: "wsl.onboarding.adding" } : { key: "wsl.server.add" },
disabled: input.adding || !!input.state?.job,
action: "add",
loading: input.adding,
width: null,
}
}
function addServerOpencodeReady(check: WslOpencodeCheck | null) {
return !!check?.resolvedPath && check.matchesDesktop !== false && !check.error
}
function addServerSelectedDistroSettled(state: WslServersState | undefined, selectedDistro: string | null) {
if (!selectedDistro) return false
const installed = state?.installed.find((item) => item.name === selectedDistro)
if (installed?.version === 1) return false
if (!state?.distroProbes[selectedDistro]) return false
if (!wslDistroReady(state, selectedDistro)) return true
return !!state.opencodeChecks[selectedDistro]
}
function addServerInstallableDistros(installedDistros: WslInstalledDistro[], onlineDistros: WslOnlineDistro[]) {
const installed = new Set(installedDistros.map((item) => item.name))
const hasVersionedUbuntu = onlineDistros.some((item) => /^Ubuntu-\d/.test(item.name))
return onlineDistros
.filter((item) => !installed.has(item.name))
.filter((item) => item.name !== "Ubuntu" || !hasVersionedUbuntu)
}
function addServerFilteredInstallableDistros(installableDistros: WslOnlineDistro[], search: string) {
const query = search.trim()
if (!query) return installableDistros
return fuzzysort.go(query, installableDistros, { keys: ["label", "name"] }).map((item) => item.obj)
}
function addServerCatalogTarget(target: string | null, distros: WslOnlineDistro[]) {
if (target && distros.some((item) => item.name === target)) return target
return distros[0]?.name ?? null
}
export function addableProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]
}) {
const state = input.state
if (!state?.runtime?.available || state.pendingRestart || input.view !== "main" || input.adding) return
if (state.job) return
const ordered = input.selectedDistro
? [
...input.addableInstalledDistros.filter((item) => item.name === input.selectedDistro),
...input.addableInstalledDistros.filter((item) => item.name !== input.selectedDistro),
]
: input.addableInstalledDistros
const pending = ordered.flatMap((item) => {
if (item.version === 1) return []
if (!state.distroProbes[item.name]) return [`distro:${item.name}`]
if (wslDistroReady(state, item.name) && !state.opencodeChecks[item.name]) return [`opencode:${item.name}`]
return []
})
if (!pending.length) return
return {
key: pending.join("|"),
distros: ordered.filter((item) => item.version !== 1).map((item) => item.name),
} satisfies AddableProbePlan
}
export function autoProbePlan(input: { state: WslServersState | undefined; busy: boolean }) {
if (!input.state || input.busy || input.state.pendingRestart) return
if (!input.state.runtime) return { key: "runtime", action: "probeRuntime" } satisfies AutoProbePlan
if (!input.state.runtime.available) return
if (input.state.installed.length || input.state.online.length) return
return { key: "distros", action: "refreshDistros" } satisfies AutoProbePlan
}
export function addServerProbePlan(input: {
state: WslServersState | undefined
view: WslAddServerView
adding: boolean
busy: boolean
selectedDistro: string | null
addableInstalledDistros: WslInstalledDistro[]
}) {
const auto = autoProbePlan({ state: input.state, busy: input.busy })
if (auto) return { kind: "auto", key: `auto:${auto.key}`, plan: auto } satisfies AddServerProbePlan
const addable = addableProbePlan(input)
if (addable) return { kind: "addable", key: `addable:${addable.key}`, plan: addable } satisfies AddServerProbePlan
}
export function createProbeFailureGate() {
let failed: string | undefined
return {
accepts(key: string) {
return key !== failed
},
settle(key: string, error?: unknown) {
if (error) failed = key
},
reset() {
failed = undefined
},
}
}
export async function runAddableProbePlan(input: {
plan: AddableProbePlan
api: Pick<WslServersPlatform, "probeAddable">
}) {
await input.api.probeAddable(input.plan.distros)
}
+1 -13
View File
@@ -1,8 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogBody, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
@@ -30,17 +28,7 @@ export function AddServerMenu(props: { onAddServer: () => void }) {
const dialog = useDialog()
const language = useLanguage()
const openAddWsl = () => {
dialog.push(() => (
<Dialog size="large" fit class="settings-v2-wsl-dialog">
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("wsl.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody>
<DialogAddWslServer />
</DialogBody>
</Dialog>
))
dialog.push(() => <DialogAddWslServer />)
}
return (
<Show
+2 -4
View File
@@ -53,8 +53,7 @@ export type WslJob =
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "probe-addable"; distros: string[]; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
@@ -77,8 +76,7 @@ export type WslServersPlatform = {
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
probeAddable(distros: string[]): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"bin": {
+3 -2
View File
@@ -1,5 +1,7 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { Context, Layer, Option } from "effect"
import * as Effect from "effect/Effect"
@@ -38,8 +40,7 @@ function bind(hostname: string, port: number, password: string) {
return Layer.build(
HttpRouter.serve(createRoutes(password), { disableListenLog: true, disableLogger: true }).pipe(
Layer.provideMerge(NodeHttpServer.layer(() => createServer(), { port, host: hostname })),
Layer.provide(Credential.defaultLayer),
Layer.provide(PermissionSaved.defaultLayer),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node]))),
),
).pipe(Effect.map((context) => Context.get(context, HttpServer.HttpServer).address))
}
+1 -1
View File
@@ -25,7 +25,7 @@ const Handlers = Runtime.handlers(Commands, {
})
Runtime.run(Commands, Handlers, { version: "local" }).pipe(
Effect.provide(Daemon.defaultLayer),
Effect.provide(Daemon.layer),
Effect.provide(NodeServices.layer),
Effect.scoped,
NodeRuntime.runMain,
-2
View File
@@ -189,6 +189,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer
export * as Daemon from "./daemon"
+2 -1
View File
@@ -1,6 +1,7 @@
import { run } from "@opencode-ai/tui"
import { TuiConfig } from "@opencode-ai/tui/config"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
export function runTui(transport: { url: string; headers: RequestInit["headers"] }) {
@@ -14,7 +15,7 @@ export function runTui(transport: { url: string; headers: RequestInit["headers"]
async start() {},
async dispose() {},
},
}).pipe(Effect.provide(Global.defaultLayer))
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
}
const legacyDefaults: Record<string, unknown> = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"scripts": {
-1
View File
@@ -248,7 +248,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "الاستثناءات التالية",
"go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع",
"go.banner.text": "MiniMax M3: حد استخدام أكبر 3 مرات لفترة محدودة",
"go.meta.description":
"يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ GLM-5.2 وGLM-5.1 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.",
"go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع",
-1
View File
@@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguintes exceções",
"go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos",
"go.banner.text": "MiniMax M3 tem limite de uso 3x maior por tempo limitado",
"go.meta.description":
"O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelos de codificação de baixo custo para todos",
-1
View File
@@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende undtagelser",
"go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle",
"go.banner.text": "MiniMax M3 får tredoblet brugsgrænse i en begrænset periode",
"go.meta.description":
"Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Kodningsmodeller til lav pris for alle",
-1
View File
@@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "folgenden Ausnahmen",
"go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle",
"go.banner.text": "MiniMax M3 erhält für begrenzte Zeit 3x Nutzungslimits",
"go.meta.description":
"Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.",
"go.hero.title": "Kostengünstige Coding-Modelle für alle",
-1
View File
@@ -248,7 +248,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "following exceptions",
"go.title": "OpenCode Go | Low cost coding models for everyone",
"go.banner.text": "MiniMax M3 gets 3× usage limits for a limited time",
"go.meta.description":
"Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.",
"go.hero.title": "Low cost coding models for everyone",
-1
View File
@@ -253,7 +253,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "siguientes excepciones",
"go.title": "OpenCode Go | Modelos de programación de bajo coste para todos",
"go.banner.text": "MiniMax M3 tiene límites de uso 3x mayores por tiempo limitado",
"go.meta.description":
"Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.",
"go.hero.title": "Modelos de programación de bajo coste para todos",
-1
View File
@@ -254,7 +254,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "exceptions suivantes",
"go.title": "OpenCode Go | Modèles de code à faible coût pour tous",
"go.banner.text": "MiniMax M3 bénéficie de limites dutilisation 3x supérieures pour une durée limitée",
"go.meta.description":
"Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.",
"go.hero.title": "Modèles de code à faible coût pour tous",
-1
View File
@@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "seguenti eccezioni",
"go.title": "OpenCode Go | Modelli di coding a basso costo per tutti",
"go.banner.text": "MiniMax M3 offre limiti di utilizzo 3x superiori per un periodo limitato",
"go.meta.description":
"Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.",
"go.hero.title": "Modelli di coding a basso costo per tutti",
-1
View File
@@ -249,7 +249,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下の例外",
"go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル",
"go.banner.text": "MiniMax M3の利用上限が期間限定で3倍に",
"go.meta.description":
"Goは最初の月$5、その後$10/月で、GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。",
"go.hero.title": "すべての人のための低価格なコーディングモデル",
-1
View File
@@ -246,7 +246,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "다음 예외",
"go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델",
"go.banner.text": "MiniMax M3 사용 한도가 한시적으로 3배 확대됩니다",
"go.meta.description":
"Go는 첫 달 $5, 이후 $10/월로 시작하며, GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.",
"go.hero.title": "모두를 위한 저비용 코딩 모델",
-1
View File
@@ -250,7 +250,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "følgende unntak",
"go.title": "OpenCode Go | Rimelige kodemodeller for alle",
"go.banner.text": "MiniMax M3 får 3x bruksgrense i en begrenset periode",
"go.meta.description":
"Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.",
"go.hero.title": "Rimelige kodemodeller for alle",
-1
View File
@@ -251,7 +251,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "następującymi wyjątkami",
"go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego",
"go.banner.text": "MiniMax M3 oferuje 3x wyższe limity użycia przez ograniczony czas",
"go.meta.description":
"Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.",
"go.hero.title": "Niskokosztowe modele do kodowania dla każdego",
-1
View File
@@ -254,7 +254,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "следующими исключениями",
"go.title": "OpenCode Go | Недорогие модели для кодинга для всех",
"go.banner.text": "MiniMax M3 получает 3x лимиты использования на ограниченное время",
"go.meta.description":
"Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.",
"go.hero.title": "Недорогие модели для кодинга для всех",
-1
View File
@@ -249,7 +249,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "ข้อยกเว้นดังนี้",
"go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
"go.banner.text": "MiniMax M3 เพิ่มโควตาการใช้งานเป็น 3 เท่าในช่วงเวลาจำกัด",
"go.meta.description":
"Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash",
"go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน",
-1
View File
@@ -252,7 +252,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "aşağıdaki istisnalar",
"go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri",
"go.banner.text": "MiniMax M3 sınırlı bir süre için 3x kullanım limiti sunuyor",
"go.meta.description":
"Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.",
"go.hero.title": "Herkes için düşük maliyetli kodlama modelleri",
-1
View File
@@ -251,7 +251,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "такими винятками",
"go.title": "OpenCode Go | Недорогі моделі кодування для всіх",
"go.banner.text": "MiniMax M3 отримує 3x ліміти використання протягом обмеженого часу",
"go.meta.description":
"Go починається від $5 за перший місяць, потім $10/місяць, з generous 5-годинними лімітами запитів для GLM-5.2, GLM-5.1, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.",
"go.hero.title": "Недорогі моделі кодування для всіх",
-1
View File
@@ -240,7 +240,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情况除外",
"go.title": "OpenCode Go | 人人可用的低成本编程模型",
"go.banner.text": "MiniMax M3 限时享受 3 倍使用额度",
"go.meta.description":
"Go 首月 $5,之后 $10/月,提供对 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。",
"go.hero.title": "人人可用的低成本编程模型",
-1
View File
@@ -240,7 +240,6 @@ export const dict = {
"zen.privacy.exceptionsLink": "以下例外情況",
"go.title": "OpenCode Go | 低成本全民編碼模型",
"go.banner.text": "MiniMax M3 限時享有 3 倍使用額度",
"go.meta.description":
"Go 首月 $5,之後 $10/月,提供對 GLM-5.2、GLM-5.1、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。",
"go.hero.title": "低成本全民編碼模型",
@@ -327,37 +327,6 @@ body {
}
}
[data-component="desktop-app-banner"] {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
[data-slot="badge"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-weight: 500;
padding: 4px 8px;
line-height: 1;
flex-shrink: 0;
}
[data-slot="content"] {
display: flex;
align-items: center;
gap: 1ch;
}
[data-slot="text"] {
color: var(--color-text-strong);
line-height: 1.4;
@media (max-width: 30.625rem) {
display: none;
}
}
}
[data-slot="hero-copy"] {
img {
margin-bottom: 24px;
@@ -663,10 +632,6 @@ body {
fill: var(--color-text-strong);
}
[data-bar][data-kind="promo"] {
fill: color-mix(in srgb, var(--bar-go) 50%, transparent);
}
[data-val] {
fill: var(--color-text-strong);
font-size: 13px;
+5 -23
View File
@@ -64,10 +64,10 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "240ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "250ms" },
{ id: "minimax-m3", name: "MiniMax M3 (3x usage)", req: 9600, baseReq: 3200, d: "280ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@@ -157,24 +157,12 @@ function LimitsGraph(props: { href: string }) {
<rect
x={left}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.baseReq ?? m.req)) - left)}
width={Math.max(0, x(ratio(m.req)) - left)}
height={bh}
data-bar
data-kind="go"
data-model={m.id}
data-segment={m.baseReq ? "base" : undefined}
/>
{m.baseReq && (
<rect
x={x(ratio(m.baseReq)) + 2}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.req)) - x(ratio(m.baseReq)) - 2)}
height={bh}
data-bar
data-kind="promo"
data-model={m.id}
/>
)}
</g>
)}
</For>
@@ -264,12 +252,6 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
<span data-slot="badge">{i18n.t("home.banner.badge")}</span>
<div data-slot="content">
<span data-slot="text">{i18n.t("go.banner.text")}</span>
</div>
</div>
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={goLogoLight} alt="" />
<img data-slot="zen logo dark" src={goLogoDark} alt="" />
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.11",
"version": "1.17.13",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.11",
"version": "1.17.13",
"$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.17.11",
"version": "1.17.13",
"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.17.11",
"version": "1.17.13",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.11",
"version": "1.17.13",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -42,7 +42,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = State.create<Data, Draft>({
-2
View File
@@ -233,5 +233,3 @@ export const locationLayer = Layer.effect(
)
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
export const defaultLayer = locationLayer
+1 -3
View File
@@ -360,8 +360,6 @@ export const make = Effect.gen(function* () {
return Service.of({ list, get, start, extend, wait, waitForPromotion, promote, cancel })
})
export const layer = Layer.effect(Service, make)
export const defaultLayer = layer
const layer = Layer.effect(Service, make)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+1 -1
View File
@@ -61,7 +61,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
+1 -1
View File
@@ -26,7 +26,7 @@ export interface Interface extends State.Transformable<Draft> {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.sync(() => {
const state = State.create<Data, Draft>({
+2 -2
View File
@@ -132,14 +132,14 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Config") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const location = yield* Location.Service
const policy = yield* Policy.Service
const names = ["config.json", "opencode.json", "opencode.jsonc"]
const names = ["opencode.json", "opencode.jsonc"]
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
@@ -66,7 +66,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
@@ -141,13 +141,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionStore.defaultLayer),
)
export const node = makeGlobalNode({
service: Service,
layer,
+1 -3
View File
@@ -48,7 +48,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Credential") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
@@ -135,6 +135,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+1 -2
View File
@@ -497,12 +497,11 @@ export const make = Effect.gen(function* () {
return makeSpawner(spawnCommand)
})
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
export const node = makeGlobalNode({ service: ChildProcessSpawner, layer, deps: [filesystem, path] })
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
+1 -7
View File
@@ -19,7 +19,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = yield* makeDatabase
@@ -54,10 +54,4 @@ export function path() {
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] })
+1 -3
View File
@@ -634,7 +634,5 @@ export const layerWith = (options?: LayerOptions) =>
}),
)
export const layer = layerWith()
const layer = layerWith()
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
+1 -1
View File
@@ -71,7 +71,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
*/
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
-4
View File
@@ -111,10 +111,6 @@ const baseLayer = Layer.effect(
}),
)
export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer))
export const locationLayer = layer
export const node = makeLocationNode({
service: Service,
layer: baseLayer,
+1 -3
View File
@@ -54,7 +54,7 @@ export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/FileWatcher") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
@@ -133,8 +133,6 @@ export const layer = Layer.effect(
),
)
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer))
export const node = makeLocationNode({
service: Service,
layer,
+1 -2
View File
@@ -49,7 +49,7 @@ export namespace FSUtil {
export const use = serviceUse(Service)
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
@@ -200,7 +200,6 @@ export namespace FSUtil {
}),
)
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] })
// Pure helpers that don't need Effect (path manipulation, sync operations)
+1 -2
View File
@@ -172,7 +172,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/GitV2") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@@ -943,7 +943,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] })
interface Result {
@@ -86,7 +86,7 @@ export async function convertToOpenAIResponsesInput({
: {
image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`,
}),
detail: part.providerOptions?.openai?.imageDetail,
detail: part.providerOptions?.copilot?.imageDetail,
}
} else if (part.mediaType === "application/pdf") {
if (part.data instanceof URL) {
@@ -127,7 +127,7 @@ export async function convertToOpenAIResponsesInput({
input.push({
role: "assistant",
content: [{ type: "output_text", text: part.text }],
id: (part.providerOptions?.openai?.itemId as string) ?? undefined,
id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
})
break
}
@@ -143,7 +143,7 @@ export async function convertToOpenAIResponsesInput({
input.push({
type: "local_shell_call",
call_id: part.toolCallId,
id: (part.providerOptions?.openai?.itemId as string) ?? undefined,
id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
action: {
type: "exec",
command: parsedInput.action.command,
@@ -162,7 +162,7 @@ export async function convertToOpenAIResponsesInput({
call_id: part.toolCallId,
name: part.toolName,
arguments: JSON.stringify(part.input),
id: (part.providerOptions?.openai?.itemId as string) ?? undefined,
id: (part.providerOptions?.copilot?.itemId as string) ?? undefined,
})
break
}
@@ -275,7 +275,7 @@ export async function convertToOpenAIResponsesInput({
const output = part.output
if (output.type === "execution-denied") {
const approvalId = (output.providerOptions?.openai as { approvalId?: string } | undefined)?.approvalId
const approvalId = (output.providerOptions?.copilot as { approvalId?: string } | undefined)?.approvalId
if (approvalId) {
continue
@@ -525,7 +525,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "reasoning" as const,
text: summary.text,
providerMetadata: {
openai: {
copilot: {
itemId: part.id,
reasoningEncryptedContent: part.encrypted_content ?? null,
},
@@ -563,7 +563,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
toolName: "local_shell",
input: JSON.stringify({ action: part.action } satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
openai: {
copilot: {
itemId: part.id,
},
},
@@ -574,7 +574,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
case "message": {
for (const contentPart of part.content) {
if (options.providerOptions?.openai?.logprobs && contentPart.logprobs) {
if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) {
logprobs.push(contentPart.logprobs)
}
@@ -582,7 +582,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "text",
text: contentPart.text,
providerMetadata: {
openai: {
copilot: {
itemId: part.id,
},
},
@@ -622,7 +622,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
toolName: part.name,
input: part.arguments,
providerMetadata: {
openai: {
copilot: {
itemId: part.id,
},
},
@@ -724,15 +724,15 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
const providerMetadata: SharedV3ProviderMetadata = {
openai: { responseId: response.id },
copilot: { responseId: response.id },
}
if (logprobs.length > 0) {
providerMetadata.openai.logprobs = logprobs
providerMetadata.copilot.logprobs = logprobs
}
if (typeof response.service_tier === "string") {
providerMetadata.openai.serviceTier = response.service_tier
providerMetadata.copilot.serviceTier = response.service_tier
}
return {
@@ -954,7 +954,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "text-start",
id: value.item.id,
providerMetadata: {
openai: {
copilot: {
itemId: value.item.id,
},
},
@@ -971,7 +971,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "reasoning-start",
id: `${value.item.id}:0`,
providerMetadata: {
openai: {
copilot: {
itemId: value.item.id,
reasoningEncryptedContent: value.item.encrypted_content ?? null,
},
@@ -994,7 +994,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
toolName: value.item.name,
input: value.item.arguments,
providerMetadata: {
openai: {
copilot: {
itemId: value.item.id,
},
},
@@ -1103,7 +1103,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
},
} satisfies z.infer<typeof localShellInputSchema>),
providerMetadata: {
openai: { itemId: value.item.id },
copilot: { itemId: value.item.id },
},
})
} else if (value.item.type === "message") {
@@ -1122,7 +1122,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "reasoning-end",
id: `${activeReasoningPart.canonicalId}:${summaryIndex}`,
providerMetadata: {
openai: {
copilot: {
itemId: activeReasoningPart.canonicalId,
reasoningEncryptedContent: value.item.encrypted_content ?? null,
},
@@ -1209,7 +1209,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "text-start",
id: currentTextId,
providerMetadata: {
openai: { itemId: value.item_id },
copilot: { itemId: value.item_id },
},
})
}
@@ -1220,7 +1220,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
delta: value.delta,
})
if (options.providerOptions?.openai?.logprobs && value.logprobs) {
if (options.providerOptions?.copilot?.logprobs && value.logprobs) {
logprobs.push(value.logprobs)
}
} else if (isResponseReasoningSummaryPartAddedChunk(value)) {
@@ -1235,7 +1235,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
type: "reasoning-start",
id: `${activeItem.canonicalId}:${value.summary_index}`,
providerMetadata: {
openai: {
copilot: {
itemId: activeItem.canonicalId,
reasoningEncryptedContent: activeItem.encryptedContent ?? null,
},
@@ -1252,7 +1252,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
id: `${activeItem.canonicalId}:${value.summary_index}`,
delta: value.delta,
providerMetadata: {
openai: {
copilot: {
itemId: activeItem.canonicalId,
},
},
@@ -1306,17 +1306,17 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
}
const providerMetadata: SharedV3ProviderMetadata = {
openai: {
copilot: {
responseId,
},
}
if (logprobs.length > 0) {
providerMetadata.openai.logprobs = logprobs
providerMetadata.copilot.logprobs = logprobs
}
if (serviceTier !== undefined) {
providerMetadata.openai.serviceTier = serviceTier
providerMetadata.copilot.serviceTier = serviceTier
}
controller.enqueue({
+1 -2
View File
@@ -71,12 +71,11 @@ export function make(input: Partial<Interface> = {}): Interface {
}
}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.sync(() => Service.of(make())),
)
export const defaultLayer = layer
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] })
export const layerWith = (input: Partial<Interface>) =>
+1 -1
View File
@@ -44,7 +44,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
+1 -1
View File
@@ -19,7 +19,7 @@ class File extends Schema.Class<File>("InstructionContext.File")({
const Files = Schema.Array(File)
const key = SystemContext.Key.make("core/instructions")
export const layer = Layer.effectDiscard(
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
+1 -1
View File
@@ -76,7 +76,7 @@ interface ResolvedPath {
const slash = (value: string) => value.replaceAll("\\", "/")
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
+1 -1
View File
@@ -16,7 +16,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
export const layer = (ref: Ref) =>
const layer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
+1 -6
View File
@@ -120,7 +120,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
@@ -239,11 +239,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(FSUtil.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev"
+1 -7
View File
@@ -69,7 +69,7 @@ interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* FSUtil.Service
@@ -248,12 +248,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(FSUtil.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
+1 -1
View File
@@ -106,7 +106,7 @@ interface Pending {
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
const events = yield* EventV2.Service
+1 -3
View File
@@ -34,7 +34,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PermissionSaved") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
@@ -76,6 +76,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+1 -2
View File
@@ -28,7 +28,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
@@ -149,7 +149,6 @@ export const locationLayer = layer.pipe(
Layer.provideMerge(CommandV2.locationLayer),
Layer.provideMerge(Integration.locationLayer),
Layer.provideMerge(Reference.locationLayer),
Layer.provideMerge(SkillV2.locationLayer),
)
export const node = makeLocationNode({
-2
View File
@@ -125,9 +125,7 @@ const layer = Layer.effectDiscard(
)
export const locationLayer = layer.pipe(
Layer.provideMerge(PluginV2.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(FetchHttpClient.layer),
)
+1 -1
View File
@@ -22,7 +22,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Policy") {}
export const layer = Layer.effect(
const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
let statements: Info[] = []

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