opencode/diff.txt
2026-04-19 13:21:48 +10:00

4594 lines
178 KiB
Text

diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index dbe1074484..5528523ab9 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,5 +1,7 @@
import "@/index.css"
+import { Button } from "@opencode-ai/ui/button"
import { I18nProvider } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
@@ -26,6 +28,7 @@ import {
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
+import { serverSwitching } from "@/utils/server-switch"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -37,6 +40,7 @@ import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
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 } from "@/context/settings"
@@ -73,7 +77,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
- wsl?: boolean
+ activeServer?: string
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
@@ -223,12 +227,15 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
+ const dialog = useDialog()
const language = useLanguage()
+ const platform = usePlatform()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
+ const canManage = createMemo(() => server.current?.type === "sidecar" && server.current?.variant === "wsl")
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
@@ -243,6 +250,34 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
+ <Show when={canManage() && !!platform.wslServers}>
+ <Button
+ variant="secondary"
+ size="large"
+ class="mt-4"
+ onClick={() => {
+ void import("@/components/dialog-select-server")
+ .then((x) => {
+ dialog.show(() => (
+ <x.DialogSelectServer
+ onNavigateHome={() => {
+ // We're above the Router here so useNavigate() isn't available.
+ // Update the browser URL directly; after server.setActive fires
+ // ServerKey remounts the Router which picks up "/" on init.
+ // Harmless under MemoryRouter (Electron), which restarts at "/".
+ if (typeof window !== "undefined" && window.history?.replaceState) {
+ window.history.replaceState(null, "", "/")
+ }
+ }}
+ />
+ ))
+ })
+ .catch((err) => console.error("Failed to load server dialog", err))
+ }}
+ >
+ Manage servers
+ </Button>
+ </Show>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
@@ -285,6 +320,12 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
+ // ServerKey wraps the whole Router so that switching `server.key` throws
+ // away any session / pty state from the previous server. Preserving the
+ // route across servers doesn't work because session ids, pty ids, and
+ // most URL-addressable resources are server-scoped — you'd 404 on every
+ // fetch. The click handler that swaps servers also navigates back to "/"
+ // so the fresh MemoryRouter doesn't try to re-resolve a now-dead URL.
return (
<ServerProvider
defaultServer={props.defaultServer}
@@ -292,6 +333,11 @@ export function AppInterface(props: {
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+ <Show when={serverSwitching()}>
+ <div class="fixed inset-0 z-[2147483647] bg-background-base flex flex-col items-center justify-center pointer-events-auto">
+ <Splash class="w-16 h-20 opacity-50 animate-pulse" />
+ </div>
+ </Show>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index dd92edec3e..93eaf0df49 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -8,9 +8,9 @@ import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
-import { useNavigate } from "@solidjs/router"
-import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
+import { batch, createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
+import { DialogWslServer } from "@/components/dialog-wsl-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@@ -19,6 +19,11 @@ import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
+interface DialogSelectServerProps {
+ initialView?: "list" | "add-wsl"
+ onNavigateHome?: () => void
+}
+
interface ServerFormProps {
value: string
name: string
@@ -171,8 +176,7 @@ function ServerForm(props: ServerFormProps) {
)
}
-export function DialogSelectServer() {
- const navigate = useNavigate()
+export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
@@ -191,6 +195,9 @@ export function DialogSelectServer() {
showForm: false,
status: undefined as boolean | undefined,
},
+ addWsl: {
+ showWizard: props.initialView === "add-wsl",
+ },
editServer: {
id: undefined as string | undefined,
value: "",
@@ -354,11 +361,13 @@ export function DialogSelectServer() {
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
- navigate("/")
+ props.onNavigateHome?.()
return
}
- navigate("/")
- queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
+ batch(() => {
+ props.onNavigateHome?.()
+ server.setActive(ServerConnection.key(conn))
+ })
}
const handleAddChange = (value: string) => {
@@ -419,7 +428,8 @@ export function DialogSelectServer() {
)
}
- const mode = createMemo<"list" | "add" | "edit">(() => {
+ const mode = createMemo<"list" | "add-wsl" | "add" | "edit">(() => {
+ if (store.addWsl.showWizard) return "add-wsl"
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@@ -433,9 +443,11 @@ export function DialogSelectServer() {
const resetForm = () => {
resetAdd()
resetEdit()
+ setStore("addWsl", "showWizard", false)
}
const startAdd = () => {
+ setStore("addWsl", "showWizard", false)
resetEdit()
setStore("addServer", {
showForm: true,
@@ -449,6 +461,7 @@ export function DialogSelectServer() {
}
const startEdit = (conn: ServerConnection.Http) => {
+ setStore("addWsl", "showWizard", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@@ -461,6 +474,12 @@ export function DialogSelectServer() {
})
}
+ const startAddWsl = () => {
+ resetAdd()
+ resetEdit()
+ setStore("addWsl", "showWizard", true)
+ }
+
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
@@ -477,14 +496,22 @@ export function DialogSelectServer() {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
+ const isAddWslMode = createMemo(() => mode() === "add-wsl")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
+ const canAddWsl = createMemo(() => !!platform.wslServers && platform.os === "windows")
const formTitle = createMemo(() => {
if (!isFormMode()) return language.t("dialog.server.title")
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
- <span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
+ <span>
+ {isAddWslMode()
+ ? "Add WSL server"
+ : isAddMode()
+ ? language.t("dialog.server.add.title")
+ : language.t("dialog.server.edit.title")}
+ </span>
</div>
)
})
@@ -495,35 +522,65 @@ export function DialogSelectServer() {
resetEdit()
})
- async function handleRemove(url: ServerConnection.Key) {
- server.remove(url)
- if ((await platform.getDefaultServer?.()) === url) {
+ async function handleRemove(key: ServerConnection.Key) {
+ server.remove(key)
+ if ((await platform.getDefaultServer?.()) === key) {
void platform.setDefaultServer?.(null)
}
}
+ async function handleRemoveWsl(conn: ServerConnection.Any) {
+ if (conn.type !== "sidecar" || conn.variant !== "wsl") return
+ const key = ServerConnection.key(conn)
+ try {
+ await platform.wslServers?.removeServer(key)
+ server.remove(key)
+ if ((await platform.getDefaultServer?.()) === key) {
+ void platform.setDefaultServer?.(null)
+ }
+ } catch (err) {
+ showRequestError(language, err)
+ }
+ }
+
+ async function handleRetryWsl(conn: ServerConnection.Any) {
+ if (conn.type !== "sidecar" || conn.variant !== "wsl") return
+ try {
+ await platform.wslServers?.startServer(ServerConnection.key(conn))
+ } catch (err) {
+ showRequestError(language, err)
+ }
+ }
+
return (
- <Dialog title={formTitle()}>
+ <Dialog title={formTitle()} dismissOutside={!isAddWslMode()}>
<div class="flex flex-col gap-2">
<Show
when={!isFormMode()}
fallback={
- <ServerForm
- value={isAddMode() ? store.addServer.url : store.editServer.value}
- name={isAddMode() ? store.addServer.name : store.editServer.name}
- username={isAddMode() ? store.addServer.username : store.editServer.username}
- password={isAddMode() ? store.addServer.password : store.editServer.password}
- placeholder={language.t("dialog.server.add.placeholder")}
- busy={formBusy()}
- error={isAddMode() ? store.addServer.error : store.editServer.error}
- status={isAddMode() ? store.addServer.status : store.editServer.status}
- onChange={isAddMode() ? handleAddChange : handleEditChange}
- onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
- onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
- onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
- onSubmit={submitForm}
- onBack={resetForm}
- />
+ <Show
+ when={isAddWslMode()}
+ fallback={
+ <ServerForm
+ value={isAddMode() ? store.addServer.url : store.editServer.value}
+ name={isAddMode() ? store.addServer.name : store.editServer.name}
+ username={isAddMode() ? store.addServer.username : store.editServer.username}
+ password={isAddMode() ? store.addServer.password : store.editServer.password}
+ placeholder={language.t("dialog.server.add.placeholder")}
+ busy={formBusy()}
+ error={isAddMode() ? store.addServer.error : store.editServer.error}
+ status={isAddMode() ? store.addServer.status : store.editServer.status}
+ onChange={isAddMode() ? handleAddChange : handleEditChange}
+ onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
+ onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
+ onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
+ onSubmit={submitForm}
+ onBack={resetForm}
+ />
+ }
+ >
+ <DialogWslServer />
+ </Show>
}
>
<List
@@ -534,7 +591,7 @@ export function DialogSelectServer() {
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
- key={(x) => x.http.url}
+ key={(x) => ServerConnection.key(x)}
onSelect={(x) => {
if (x) void select(x)
}}
@@ -543,6 +600,7 @@ export function DialogSelectServer() {
>
{(i) => {
const key = ServerConnection.key(i)
+ const isWslSidecar = i.type === "sidecar" && i.variant === "wsl"
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
@@ -562,12 +620,12 @@ export function DialogSelectServer() {
}
showCredentials
/>
- <div class="flex items-center justify-center gap-4 pl-4">
+ <div class="flex items-center justify-center gap-3 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
- <Show when={i.type === "http"}>
+ <Show when={i.type === "http" || isWslSidecar}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -579,35 +637,46 @@ export function DialogSelectServer() {
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
- <DropdownMenu.Item
- onSelect={() => {
- if (i.type !== "http") return
- startEdit(i)
- }}
- >
- <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <Show when={canDefault() && defaultKey() !== key}>
+ <Show when={i.type === "http"}>
+ <DropdownMenu.Item
+ onSelect={() => {
+ if (i.type !== "http") return
+ startEdit(i)
+ }}
+ >
+ <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </Show>
+ <Show when={isWslSidecar && store.status[key]?.healthy === false}>
+ <DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
+ <DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </Show>
+ <Show when={i.type === "http" && canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
- <Show when={canDefault() && defaultKey() === key}>
+ <Show when={i.type === "http" && canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- onSelect={() => handleRemove(ServerConnection.key(i))}
- class="text-text-on-critical-base hover:bg-surface-critical-weak"
- >
- <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
+ <Show when={i.type === "http" || isWslSidecar}>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item
+ onSelect={() => (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))}
+ class="text-text-on-critical-base hover:bg-surface-critical-weak"
+ >
+ <DropdownMenu.ItemLabel>
+ {language.t("dialog.server.menu.delete")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
@@ -621,17 +690,32 @@ export function DialogSelectServer() {
<div class="px-5 pb-5">
<Show
- when={isFormMode()}
+ when={!isAddWslMode() && isFormMode()}
fallback={
- <Button
- variant="secondary"
- icon="plus-small"
- size="large"
- onClick={startAdd}
- class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
- >
- {language.t("dialog.server.add.button")}
- </Button>
+ <Show when={!isAddWslMode()}>
+ <div class="flex items-center gap-2">
+ <Button
+ variant="secondary"
+ icon="plus-small"
+ size="large"
+ onClick={startAdd}
+ class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
+ >
+ {language.t("dialog.server.add.button")}
+ </Button>
+ <Show when={canAddWsl()}>
+ <Button
+ variant="secondary"
+ icon="plus-small"
+ size="large"
+ onClick={startAddWsl}
+ class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
+ >
+ Add WSL
+ </Button>
+ </Show>
+ </div>
+ </Show>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx
new file mode 100644
index 0000000000..d971051eec
--- /dev/null
+++ b/packages/app/src/components/dialog-wsl-server.tsx
@@ -0,0 +1,611 @@
+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 { createStore, reconcile } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import type { WslServerStep, WslServersState } from "@/context/platform"
+import { usePlatform } from "@/context/platform"
+
+const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
+
+interface DialogWslServerProps {
+ onAdded?: () => void
+}
+
+export function DialogWslServer(props: DialogWslServerProps = {}) {
+ const language = useLanguage()
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const [store, setStore] = createStore({
+ state: undefined as WslServersState | undefined,
+ loading: true,
+ step: undefined as WslServerStep | undefined,
+ selectedDistro: null as string | null,
+ installTarget: undefined as string | undefined,
+ adding: false,
+ })
+
+ createEffect(() => {
+ const wslServers = platform.wslServers
+ if (!wslServers) return
+ let mounted = true
+ void wslServers
+ .getState()
+ .then((state) => {
+ if (!mounted) return
+ setStore({ state, loading: false })
+ })
+ .catch((err) => {
+ if (!mounted) return
+ requestError(language, err)
+ setStore("loading", false)
+ })
+ const off = wslServers.subscribe((event) => {
+ setStore("state", reconcile(event.state))
+ setStore("loading", false)
+ })
+ onCleanup(() => {
+ mounted = false
+ off()
+ })
+ })
+
+ const current = () => store.state
+ const wslServers = () => platform.wslServers
+ const busy = createMemo(() => !!current()?.job || store.adding)
+ const selectedDistro = () => store.selectedDistro
+ 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 defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null)
+ const opencodeCheck = createMemo(() => {
+ const distro = selectedDistro()
+ if (!distro) return null
+ return current()?.opencodeChecks[distro] ?? null
+ })
+ const distroWarningProbe = createMemo(() => {
+ const probe = selectedProbe()
+ if (!probe) return null
+ if (distroReady() && !probe.isRoot) return null
+ return probe
+ })
+ const distroUnavailableMessage = createMemo(() => {
+ const probe = distroWarningProbe()
+ const distro = selectedDistro()
+ if (!probe || probe.canExecute || !distro) return null
+ if (!selectedInstalled()) return `${distro} is not installed yet.`
+ return `Open ${distro} once to finish setup.`
+ })
+ const distroMissingTools = createMemo(() => {
+ const probe = distroWarningProbe()
+ if (!probe?.canExecute) return null
+ if (probe.hasBash && probe.hasCurl) return null
+ return probe
+ })
+ const opencodeMismatchCheck = createMemo(() => {
+ const check = opencodeCheck()
+ return check?.matchesDesktop === false ? check : null
+ })
+ const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
+ const addableInstalledDistros = createMemo(() => {
+ return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name))
+ })
+ const installableDistros = createMemo(() => {
+ const online = current()?.online ?? []
+ const installed = new Set((current()?.installed ?? []).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) ?? 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 allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
+ 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 autoProbe = createMemo(() => {
+ const state = current()
+ if (!state || !wslServers() || busy()) return null
+ if (state.pendingRestart) return null
+ if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() }
+ if (!wslReady()) return null
+ if (!state.installed.length && !state.online.length) {
+ return { key: "distros", run: () => wslServers()!.refreshDistros() }
+ }
+ const distro = selectedDistro()
+ if (distro && !state.distroProbes[distro]) {
+ return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) }
+ }
+ if (!distro || !distroReady()) return null
+ if (!state.opencodeChecks[distro]) {
+ return { key: `probe-opencode:${distro}`, run: () => wslServers()!.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) {
+ // 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)
+ }
+ })()
+ })
+
+ createEffect(() => {
+ const state = current()
+ const distro = defaultInstalledDistro()
+ if (!state || !distro || busy()) return
+ if (selectedDistro()) return
+ if (existingServerDistros().has(distro.name)) return
+ setStore("selectedDistro", distro.name)
+ })
+
+ createEffect(() => {
+ const distros = installableDistros()
+ if (!distros.length) {
+ if (store.installTarget) setStore("installTarget", undefined)
+ return
+ }
+ if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
+ setStore("installTarget", distros[0]!.name)
+ })
+
+ const wslMessage = createMemo(() => {
+ const state = current()
+ if (!state || state.job?.kind === "runtime") return "Checking WSL..."
+ if (state.pendingRestart) return "Windows needs a restart to finish installing WSL."
+ if (state.runtime?.available) return state.runtime.version ?? "WSL is ready."
+ return state.runtime?.error ?? "WSL is required to continue."
+ })
+
+ const distroMessage = createMemo(() => {
+ const state = current()
+ if (!state) return "Checking distros..."
+ const distro = selectedDistro()
+ if (state.job?.kind === "install-distro") return `Installing ${state.job.distro}...`
+ if (state.job?.kind === "probe-distro") return `Checking ${state.job.distro}...`
+ if (state.job?.kind === "distros") return "Listing distros..."
+ if (distroUnavailableMessage()) return distroUnavailableMessage()!
+ if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
+ if (distro) return `Finishing setup for ${distro}.`
+ return "Pick a distro or install one below."
+ })
+
+ const opencodeMessage = createMemo(() => {
+ const state = current()
+ if (!state) return "Checking OpenCode..."
+ const distro = selectedDistro()
+ if (state.job?.kind === "probe-opencode" || state.job?.kind === "install-opencode") {
+ return distro ? `Checking OpenCode in ${distro}...` : "Checking OpenCode..."
+ }
+ if (opencodeCheck()?.error) return opencodeCheck()!.error
+ if (opencodeCheck()?.matchesDesktop === false) {
+ return distro ? `Update OpenCode in ${distro}.` : "Update OpenCode."
+ }
+ if (opencodeReady()) return distro ? `OpenCode is ready in ${distro}.` : "OpenCode is ready."
+ return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
+ })
+
+ const installProgress = createMemo(() => {
+ const state = current()
+ if (!state?.job) return null
+ const transcript = state.transcript.filter((line) => line.text.trim())
+ const title = transcript[0]?.text
+ if (!title?.startsWith("Installing ")) return null
+ return {
+ title,
+ lines: transcript.slice(1).slice(-8),
+ }
+ })
+
+ const run = async (action: () => Promise<void>) => {
+ try {
+ await action()
+ } catch (err) {
+ requestError(language, err)
+ }
+ }
+
+ const selectDistro = (name: string) => {
+ setStore("selectedDistro", name)
+ setStore("step", "distro")
+ }
+
+ const finish = async () => {
+ const distro = selectedDistro()
+ if (!distro) return
+ const api = wslServers()
+ if (!api) return
+ setStore("adding", true)
+ try {
+ await api.addServer(distro)
+ props.onAdded?.()
+ dialog.close()
+ } catch (err) {
+ requestError(language, err)
+ } finally {
+ setStore("adding", false)
+ }
+ }
+
+ const steps = createMemo(() =>
+ STEPS.map((step) => ({
+ step,
+ title: stepTitle(step),
+ state: stepState(step, {
+ active: activeStep(),
+ wslReady: wslReady(),
+ distroReady: distroReady(),
+ opencodeReady: opencodeReady(),
+ opencodeMismatch: opencodeCheck()?.matchesDesktop === false,
+ }),
+ locked: stepIndex(step) > stepIndex(recommendedStep()),
+ })),
+ )
+
+ return (
+ <div class="px-5 pb-5 flex flex-col gap-4">
+ <Show when={!store.loading} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
+ <div class="flex gap-2 overflow-x-auto pb-1">
+ <For each={steps()}>
+ {(item) => (
+ <button
+ type="button"
+ class="min-w-[132px] 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">WSL</div>
+ <Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
+ <Button
+ variant="secondary"
+ size="large"
+ disabled={busy()}
+ onClick={() => void run(() => wslServers()!.installWsl())}
+ >
+ Install WSL
+ </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 flex items-center justify-between gap-3">
+ <div class="text-12-regular text-text-warning-base">Windows restart required.</div>
+ <Button variant="secondary" size="large" onClick={() => void platform.restart()}>
+ Restart OpenCode
+ </Button>
+ </div>
+ </Show>
+ </div>
+ </Match>
+
+ <Match when={activeStep() === "distro"}>
+ <div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
+ <div class="text-14-medium text-text-strong">Choose a distro</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">
+ {current()?.installed.length
+ ? "All installed distros are already added."
+ : current()?.runtime?.available
+ ? "No distros detected yet."
+ : "Checking distros..."}
+ </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>
+ <div class="text-12-regular text-text-weak">
+ {[item.isDefault ? "default" : null, item.state, item.version ? `WSL ${item.version}` : null]
+ .filter(Boolean)
+ .join(" · ")}
+ </div>
+ </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">Install</div>
+ <Button
+ variant="secondary"
+ size="small"
+ disabled={busy() || !installTarget()}
+ onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
+ >
+ Install
+ </Button>
+ </div>
+ <div
+ role="radiogroup"
+ aria-label="Install distro"
+ class="max-h-44 overflow-y-auto rounded-md bg-background-base"
+ >
+ <For each={installableDistros()}>
+ {(item) => {
+ const selected = () => store.installTarget === item.name
+ return (
+ <button
+ type="button"
+ role="radio"
+ aria-checked={selected()}
+ disabled={busy()}
+ class="w-full px-3 py-2 flex items-start 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">
+ <div class="text-13-medium text-text-strong break-words">{item.label}</div>
+ <Show when={item.label !== item.name}>
+ <div class="text-12-regular text-text-weak break-words">{item.name}</div>
+ </Show>
+ </div>
+ </button>
+ )
+ }}
+ </For>
+ </div>
+ </div>
+ </Show>
+
+ <Show
+ when={
+ selectedInstalled()?.version === 1 ||
+ distroUnavailableMessage() ||
+ distroMissingTools() ||
+ distroWarningProbe()?.isRoot
+ }
+ >
+ <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">WSL 2 is required.</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">This distro needs bash and curl.</div>
+ </Show>
+ <Show when={distroWarningProbe()?.isRoot}>
+ <div class="text-12-regular text-text-warning-base">
+ This distro is using the root user right now.
+ </div>
+ </Show>
+ </div>
+ </Show>
+
+ <Button
+ variant="secondary"
+ size="large"
+ disabled={busy() || !selectedInstalled()}
+ onClick={() => {
+ const distro = selectedDistro()
+ if (!distro) return
+ void run(() => wslServers()!.openTerminal(distro))
+ }}
+ >
+ Open terminal
+ </Button>
+ </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">OpenCode</div>
+ <Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
+ <Button
+ variant="secondary"
+ size="large"
+ disabled={busy()}
+ onClick={() => {
+ const distro = selectedDistro()
+ if (!distro) return
+ void run(() => wslServers()!.installOpencode(distro))
+ }}
+ >
+ {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
+ </Button>
+ </Show>
+ </div>
+ <div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
+ <Show when={opencodeMismatchCheck()}>
+ {(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">Path: {check().resolvedPath ?? "not found"}</div>
+ <div class="text-12-regular text-text-weak">
+ Version: {check().version ?? "unknown"}
+ <Show when={check().expectedVersion}>
+ {(expected) => <span>{` · desktop ${expected()}`}</span>}
+ </Show>
+ </div>
+ <div class="text-12-regular text-text-warning-base">
+ Installed version does not match the desktop app version.
+ </div>
+ </div>
+ )}
+ </Show>
+ </div>
+ </Match>
+ </Switch>
+
+ <Show when={installProgress()}>
+ {(progress) => (
+ <div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
+ <div class="flex items-center gap-2 text-14-medium text-text-strong">
+ <Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
+ <div>Progress</div>
+ </div>
+ <div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
+ <div
+ data-scrollable
+ class="max-h-32 overflow-y-auto rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words"
+ >
+ <For
+ each={
+ progress().lines.length
+ ? progress().lines
+ : [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
+ }
+ >
+ {(line) => (
+ <div
+ classList={{
+ "text-text-warning-base": line.stream === "stderr",
+ "text-text-weak": line.stream !== "stderr",
+ }}
+ >
+ {line.text}
+ </div>
+ )}
+ </For>
+ </div>
+ </div>
+ )}
+ </Show>
+
+ <Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
+ <div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
+ <div class="text-14-medium text-text-strong">Diagnostics</div>
+ <div class="max-h-56 overflow-y-auto rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
+ <For each={current()?.transcript ?? []}>{(line) => <div>{line.text}</div>}</For>
+ </div>
+ </div>
+ </Show>
+
+ <div class="flex items-center justify-end gap-2">
+ <Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
+ Cancel
+ </Button>
+ <Button
+ variant="primary"
+ size="large"
+ disabled={!allReady() || !selectedDistro() || store.adding || busy()}
+ onClick={() => void finish()}
+ >
+ {store.adding ? "Adding..." : "Add WSL server"}
+ </Button>
+ </div>
+ </Show>
+ </div>
+ )
+}
+
+function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
+ console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+}
+
+function stepIndex(step: WslServerStep) {
+ return STEPS.indexOf(step)
+}
+
+function stepTitle(step: WslServerStep) {
+ if (step === "wsl") return "WSL"
+ if (step === "distro") return "Choose distro"
+ return "OpenCode"
+}
+
+function stepState(
+ step: WslServerStep,
+ state: {
+ active: WslServerStep
+ wslReady: boolean
+ distroReady: boolean
+ opencodeReady: boolean
+ opencodeMismatch: boolean
+ },
+) {
+ if (state.active === step) return "current"
+ if (step === "wsl") return state.wslReady ? "done" : "warning"
+ if (step === "distro")
+ return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning"
+ return state.opencodeMismatch
+ ? "warning"
+ : state.opencodeReady
+ ? "done"
+ : stepIndex(step) > stepIndex(state.active)
+ ? "locked"
+ : "warning"
+}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 021e5be67e..3b4bda7f27 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -6,9 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
+import { StatusPopover } from "../status-popover"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/shared/util/path"
-import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -24,7 +25,6 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
-import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
@@ -129,6 +129,13 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
})
}
+function titlebarMounts() {
+ return {
+ center: document.getElementById("opencode-titlebar-center") as HTMLDivElement | undefined,
+ right: document.getElementById("opencode-titlebar-right") as HTMLDivElement | undefined,
+ }
+}
+
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
@@ -219,6 +226,7 @@ export function SessionHeader() {
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
+ const [mounts, setMounts] = createStore(titlebarMounts())
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
@@ -232,6 +240,19 @@ export function SessionHeader() {
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
+ const syncMounts = () => {
+ const next = titlebarMounts()
+ if (mounts.center === next.center && mounts.right === next.right) return
+ setMounts(next)
+ }
+
+ onMount(() => {
+ syncMounts()
+ const observer = new MutationObserver(() => syncMounts())
+ observer.observe(document.body, { childList: true, subtree: true })
+ onCleanup(() => observer.disconnect())
+ })
+
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
@@ -269,12 +290,8 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
- const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
- const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
- onMount(() => {
- setCenterMount(document.getElementById("opencode-titlebar-center"))
- setRightMount(document.getElementById("opencode-titlebar-right"))
- })
+ const centerMount = createMemo(() => mounts.center)
+ const rightMount = createMemo(() => mounts.right)
return (
<>
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index 0f6a1c1355..cad0b0673a 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -6,7 +6,7 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
-import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
+import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
@@ -15,6 +15,7 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
+import { setServerSwitching } from "@/utils/server-switch"
const pollMs = 10_000
@@ -292,8 +293,26 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
- navigate("/")
- queueMicrotask(() => server.setActive(key))
+ // Paint a full-window splash BEFORE the heavy
+ // ServerKey remount so the user gets visual
+ // feedback during the multi-second synchronous
+ // dispose cascade (xterm + file-tree + providers).
+ // setTimeout(0) yields to the browser so the
+ // splash lands on screen before the cascade
+ // starts; a second setTimeout(0) after the batch
+ // waits for the new subtree to paint, then
+ // dismisses the splash.
+ setServerSwitching(true)
+ setTimeout(() => {
+ try {
+ batch(() => {
+ navigate("/")
+ server.setActive(key)
+ })
+ } finally {
+ setTimeout(() => setServerSwitching(false), 0)
+ }
+ }, 0)
}}
>
<ServerHealthIndicator health={health[key]} />
@@ -329,7 +348,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
+ dialog.show(
+ () => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />,
+ defaultServer.refresh,
+ )
})
}}
>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 57e91d6d33..edbbd752c9 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
-import { monoFontFamily, useSettings } from "@/context/settings"
+import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
@@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => {
})
createEffect(() => {
- const font = monoFontFamily(settings.appearance.font())
+ const font = terminalFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
@@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => {
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
- fontFamily: monoFontFamily(settings.appearance.font()),
+ fontFamily: terminalFontFamily(settings.appearance.font()),
allowTransparency: false,
convertEol: false,
theme: terminalColors(),
@@ -613,17 +613,30 @@ export const Terminal = (props: TerminalProps) => {
drop?.()
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
+ // Defer finalize (persistTerminal + local cleanup()) to a microtask so
+ // that its synchronous store write inside `persistTerminal` — which
+ // flows through `props.onCleanup` -> `ops.update` -> `update()` in
+ // `context/terminal.tsx` and calls `setStore("all", i, ...)` — does
+ // NOT run inside the outer solid cleanNode cascade. Running it
+ // synchronously mid-cascade races with solid's recursive owned
+ // iteration (readSignal on a stale memo re-enters updateComputation,
+ // which nulls an ancestor's owned while the outer loop is still
+ // iterating it) and crashes with "Cannot read properties of null
+ // (reading '1')" at node.owned[i] inside chunk-EZWYHVNM.js cleanNode.
+ // queueMicrotask runs after the current sync reactive flush, so the
+ // store write lands in a fresh tick.
const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, id, onCleanup: props.onCleanup })
cleanup()
}
+ const schedule = () => queueMicrotask(finalize)
if (!output) {
- finalize()
+ schedule()
return
}
- output.flush(finalize)
+ output.flush(schedule)
})
return (
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index 6788e8cc59..2d138e72f5 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -96,8 +96,15 @@ export function createChildStoreManager(input: {
lifecycle.delete(directory)
const dispose = disposers.get(directory)
if (dispose) {
- dispose()
disposers.delete(directory)
+ // Defer the actual solid-js root disposal. When disposeDirectory runs
+ // from pinForOwner's onCleanup during a parent remount, calling
+ // dispose() here triggers a nested cleanNode cascade on the inner
+ // root while the outer cascade is mid-traversal, which corrupts
+ // solid-js's graph walk state and throws `Cannot read properties of
+ // null (reading '1')` at chunk-*.js:992. Running dispose on a
+ // microtask lets the outer cleanup finish first.
+ queueMicrotask(dispose)
}
delete children[directory]
input.onDispose(directory)
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 3bdc46391b..75e04a4a5b 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -9,6 +9,111 @@ type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: stri
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
+export type WslServerStep = "wsl" | "distro" | "opencode"
+
+export type WslRuntimeCheck = {
+ available: boolean
+ version: string | null
+ status: string | null
+ error: string | null
+}
+export type WslInstalledDistro = {
+ name: string
+ state: string | null
+ version: number | null
+ isDefault: boolean
+}
+export type WslOnlineDistro = {
+ name: string
+ label: string
+}
+export type WslDistroProbe = {
+ name: string
+ canExecute: boolean
+ hasBash: boolean
+ hasCurl: boolean
+ username: string | null
+ isRoot: boolean | null
+ error: string | null
+}
+export type WslOpencodeCheck = {
+ distro: string
+ resolvedPath: string | null
+ version: string | null
+ expectedVersion: string | null
+ matchesDesktop: boolean | null
+ error: string | null
+}
+export type WslTranscriptLine = {
+ stream: "stdout" | "stderr" | "system"
+ text: string
+ at: number
+}
+
+export type WslServerAcknowledgements = {
+ root: boolean
+ mismatch: { path: string; version: string } | null
+}
+
+export type WslServerConfig = {
+ id: string
+ distro: string
+ acknowledgements: WslServerAcknowledgements
+}
+
+export type WslServerRuntime =
+ | { kind: "starting" }
+ | { kind: "ready"; url: string; username: string | null; password: string | null }
+ | { kind: "failed"; message: string }
+ | { kind: "stopped" }
+
+export type WslServerItem = {
+ config: WslServerConfig
+ runtime: WslServerRuntime
+}
+
+export type WslJob =
+ | { kind: "runtime"; startedAt: number }
+ | { 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: "install-opencode"; distro: string; startedAt: number }
+
+export type WslServersState = {
+ runtime: WslRuntimeCheck | null
+ installed: WslInstalledDistro[]
+ online: WslOnlineDistro[]
+ distroProbes: Record<string, WslDistroProbe>
+ opencodeChecks: Record<string, WslOpencodeCheck>
+ pendingRestart: boolean
+ servers: WslServerItem[]
+ job: WslJob | null
+ transcript: WslTranscriptLine[]
+ lastError: string | null
+}
+export type WslServersEvent = { type: "state"; state: WslServersState }
+
+export type WslServersPlatform = {
+ getState(): Promise<WslServersState>
+ subscribe(cb: (event: WslServersEvent) => void): () => void
+ probeRuntime(): Promise<void>
+ refreshDistros(): Promise<void>
+ installWsl(): Promise<void>
+ installDistro(name: string): Promise<void>
+ probeDistro(name: string): Promise<void>
+ probeOpencode(name: string): Promise<void>
+ installOpencode(name: string): Promise<void>
+ openTerminal(name: string): Promise<void>
+ addServer(distro: string): Promise<WslServerConfig>
+ removeServer(id: string): Promise<void>
+ startServer(id: string): Promise<void>
+ stopServer(id: string): Promise<void>
+ cancelJob(): Promise<void>
+ updateAcknowledgements(id: string, acks: Partial<WslServerAcknowledgements>): Promise<void>
+}
+
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
@@ -64,11 +169,8 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServer?(url: ServerConnection.Key | null): Promise<void> | void
- /** Get the configured WSL integration (desktop only) */
- getWslEnabled?(): Promise<boolean>
-
- /** Set the configured WSL integration (desktop only) */
- setWslEnabled?(config: boolean): Promise<void> | void
+ /** Manage WSL sidecar servers (Electron on Windows only) */
+ wslServers?: WslServersPlatform
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 9b666e5e75..0d1cee7107 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -232,10 +232,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
- for (const entry of cache.values()) {
- entry.dispose()
- }
+ // Defer the dispose calls to a microtask; synchronous nested dispose
+ // inside a parent onCleanup corrupts solid-js's in-flight cleanNode
+ // traversal during mass remounts (see context/terminal.tsx for the
+ // same pattern).
+ const pending = Array.from(cache.values(), (entry) => entry.dispose)
cache.clear()
+ if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
}
onCleanup(disposeAll)
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 1204fba557..096ef23db9 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -23,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
- if (key === "sidecar") return "local"
+ if (key === "sidecar" || key === "local:windows") return "local"
if (isLocalHost(key)) return "local"
return key
}
@@ -81,7 +81,7 @@ export namespace ServerConnection {
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
- return Key.make("sidecar")
+ return Key.make("local:windows")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
@@ -200,7 +200,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
- const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
+ const check = (conn: ServerConnection.Any) =>
+ checkServerHealth(conn.http).then((x) => {
+ if (!x.healthy) {
+ // Electron's console-message bridge only preserves the first
+ // console argument, so pre-stringify everything into one string.
+ console.warn(
+ `[server health] unhealthy key=${ServerConnection.key(conn)} url=${conn.http.url} hasAuth=${!!(
+ conn.http.username || conn.http.password
+ )}`,
+ )
+ }
+ return x.healthy
+ })
createEffect(() => {
const current_ = current()
@@ -211,9 +223,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return
}
setState("healthy", undefined)
+ console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`)
onCleanup(startHealthPolling(current_))
})
+ createEffect(() => {
+ const key = state.active
+ if (typeof window === "undefined") return
+ window.__OPENCODE__ ??= {}
+ window.__OPENCODE__.activeServer = key
+ })
+
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -221,7 +241,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
)
const isLocal = createMemo(() => {
const c = current()
- return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
+ return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
})
return {
diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx
index a585789ce4..1534b173eb 100644
--- a/packages/app/src/context/settings.tsx
+++ b/packages/app/src/context/settings.tsx
@@ -53,9 +53,13 @@ export const sansDefault = "System Sans"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
+const terminalMonoFallback =
+ '"Symbols Nerd Font Mono", "Symbols Nerd Font", "JetBrainsMono NFM", "JetBrainsMono NF", "JetBrainsMono Nerd Font Mono", "Hack Nerd Font Mono", "Hack Nerd Font", "MesloLGM Nerd Font Mono", "MesloLGM Nerd Font", "CaskaydiaCove NFM", "CaskaydiaCove Nerd Font Mono", "CaskaydiaMono Nerd Font Mono", ' +
+ monoFallback
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = monoFallback
+const terminalMonoBase = terminalMonoFallback
const sansBase = sansFallback
function input(font: string | undefined) {
@@ -85,6 +89,10 @@ export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase)
}
+export function terminalFontFamily(font: string | undefined) {
+ return stack(font, terminalMonoBase)
+}
+
export function sansFontFamily(font: string | undefined) {
return stack(font, sansBase)
}
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 31d2d6e04c..482f55c716 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -364,10 +364,15 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
- for (const entry of cache.values()) {
- entry.dispose()
- }
+ // Snapshot disposers, then defer them to a microtask. When this runs
+ // from onCleanup during a parent remount (e.g. switching servers),
+ // calling dispose() synchronously starts a nested cleanNode cascade on
+ // a sibling root while the outer cascade is mid-traversal, corrupting
+ // solid-js's graph walk state and throwing `Cannot read properties of
+ // null (reading '1')` at chunk-*.js:992.
+ const pending = Array.from(cache.values(), (entry) => entry.dispose)
cache.clear()
+ if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
}
onCleanup(disposeAll)
diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts
index d80e9fffb0..4173cf9ca7 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,7 +1,21 @@
export { AppBaseProviders, AppInterface } from "./app"
+export { DialogWslServer } from "./components/dialog-wsl-server"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
-export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
+export {
+ type DisplayBackend,
+ type Platform,
+ PlatformProvider,
+ type WslInstalledDistro,
+ type WslOnlineDistro,
+ type WslOpencodeCheck,
+ type WslServerConfig,
+ type WslServerItem,
+ type WslServersEvent,
+ type WslServersPlatform,
+ type WslServersState,
+ type WslServerStep,
+} from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 46cacdf627..b779ebd4f5 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -75,7 +75,7 @@ export default function Home() {
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
- onClick={() => dialog.show(() => <DialogSelectServer />)}
+ onClick={() => dialog.show(() => <DialogSelectServer onNavigateHome={() => navigate("/")} />)}
>
<div
classList={{
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 12a2bf763a..51f3b2e9c3 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -83,9 +83,16 @@ import {
LocalWorkspace,
SortableWorkspace,
WorkspaceDragOverlay,
+ workspaceSortableDirectory,
+ workspaceSortableId,
type WorkspaceSidebarContext,
} from "./layout/sidebar-workspace"
-import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
+import {
+ ProjectDragOverlay,
+ SortableProject,
+ projectSortableWorktree,
+ type ProjectSidebarContext,
+} from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
@@ -1208,7 +1215,7 @@ export default function Layout(props: ParentProps) {
const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />)
+ dialog.show(() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />)
})
}
@@ -1840,7 +1847,7 @@ export default function Layout(props: ParentProps) {
)
function handleDragStart(event: unknown) {
- const id = getDraggableId(event)
+ const id = projectSortableWorktree(getDraggableId(event))
if (!id) return
setHoverProject(undefined)
setStore("activeProject", id)
@@ -1849,11 +1856,14 @@ export default function Layout(props: ParentProps) {
function handleDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (draggable && droppable) {
+ const from = projectSortableWorktree(draggable.id?.toString())
+ const to = projectSortableWorktree(droppable.id?.toString())
+ if (!from || !to) return
const projects = layout.projects.list()
- const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
- const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
+ const fromIndex = projects.findIndex((p) => p.worktree === from)
+ const toIndex = projects.findIndex((p) => p.worktree === to)
if (fromIndex !== toIndex && toIndex !== -1) {
- layout.projects.move(draggable.id.toString(), toIndex)
+ layout.projects.move(from, toIndex)
}
}
}
@@ -1891,7 +1901,7 @@ export default function Layout(props: ParentProps) {
})
function handleWorkspaceDragStart(event: unknown) {
- const id = getDraggableId(event)
+ const id = workspaceSortableDirectory(getDraggableId(event))
if (!id) return
setStore("activeWorkspace", id)
}
@@ -1899,13 +1909,16 @@ export default function Layout(props: ParentProps) {
function handleWorkspaceDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (!draggable || !droppable) return
+ const from = workspaceSortableDirectory(draggable.id?.toString())
+ const to = workspaceSortableDirectory(droppable.id?.toString())
+ if (!from || !to) return
const project = sidebarProject()
if (!project) return
const ids = workspaceIds(project)
- const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
- const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
+ const fromIndex = ids.findIndex((dir) => dir === from)
+ const toIndex = ids.findIndex((dir) => dir === to)
if (fromIndex === -1 || toIndex === -1) return
if (fromIndex === toIndex) return
@@ -2265,7 +2278,7 @@ export default function Layout(props: ParentProps) {
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
>
- <SortableProvider ids={workspaces()}>
+ <SortableProvider ids={workspaces().map(workspaceSortableId)}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace
@@ -2327,6 +2340,7 @@ export default function Layout(props: ParentProps) {
}
const projects = () => layout.projects.list()
+ const projectIds = createMemo(() => projects().map((project) => project.worktree))
const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
const sidebarContent = (mobile?: boolean) => (
<SidebarContent
@@ -2334,9 +2348,17 @@ export default function Layout(props: ParentProps) {
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={projects}
- renderProject={(project) => (
- <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
- )}
+ projectIds={projectIds}
+ renderProject={(worktree) => {
+ const project = createMemo(() => projects().find((item) => item.worktree === worktree))
+ return (
+ <Show when={project()}>
+ {(project) => (
+ <SortableProject ctx={projectSidebarCtx} project={project()} sortNow={sortNow} mobile={mobile} />
+ )}
+ </Show>
+ )
+ }}
handleDragStart={handleDragStart}
handleDragEnd={handleDragEnd}
handleDragOver={handleDragOver}
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
index 076e1ef88b..d681cf3218 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -34,6 +34,17 @@ export type ProjectSidebarContext = {
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
}
+const PROJECT_SORTABLE_PREFIX = "project:"
+
+export function projectSortableId(worktree: string) {
+ return `${PROJECT_SORTABLE_PREFIX}${worktree}`
+}
+
+export function projectSortableWorktree(id: string | undefined) {
+ if (!id?.startsWith(PROJECT_SORTABLE_PREFIX)) return
+ return id.slice(PROJECT_SORTABLE_PREFIX.length)
+}
+
export const ProjectDragOverlay = (props: {
projects: Accessor<LocalProject[]>
activeProject: Accessor<string | undefined>
@@ -275,7 +286,7 @@ export const SortableProject = (props: {
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
- const sortable = createSortable(props.project.worktree)
+ const sortable = createSortable(projectSortableId(props.project.worktree))
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx
index ca36af2a42..d9cd4d5a20 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -11,13 +11,15 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout"
+import { projectSortableId } from "./sidebar-project"
export const SidebarContent = (props: {
mobile?: boolean
opened: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
- renderProject: (project: LocalProject) => JSX.Element
+ projectIds: Accessor<string[]>
+ renderProject: (worktree: string) => JSX.Element
handleDragStart: (event: unknown) => void
handleDragEnd: () => void
handleDragOver: (event: DragEvent) => void
@@ -63,8 +65,8 @@ export const SidebarContent = (props: {
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
- <SortableProvider ids={props.projects().map((p) => p.worktree)}>
- <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
+ <SortableProvider ids={props.projectIds().map(projectSortableId)}>
+ <For each={props.projectIds()}>{(worktree) => props.renderProject(worktree)}</For>
</SortableProvider>
<Tooltip
placement={placement()}
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index c1836fa8a4..18cd7bcab7 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -54,6 +54,17 @@ export type WorkspaceSidebarContext = {
setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
}
+const WORKSPACE_SORTABLE_PREFIX = "workspace:"
+
+export function workspaceSortableId(directory: string) {
+ return `${WORKSPACE_SORTABLE_PREFIX}${directory}`
+}
+
+export function workspaceSortableDirectory(id: string | undefined) {
+ if (!id?.startsWith(WORKSPACE_SORTABLE_PREFIX)) return
+ return id.slice(WORKSPACE_SORTABLE_PREFIX.length)
+}
+
export const WorkspaceDragOverlay = (props: {
sidebarProject: Accessor<LocalProject | undefined>
activeWorkspace: Accessor<string | undefined>
@@ -300,7 +311,7 @@ export const SortableWorkspace = (props: {
const params = useParams()
const globalSync = useGlobalSync()
const language = useLanguage()
- const sortable = createSortable(props.directory)
+ const sortable = createSortable(workspaceSortableId(props.directory))
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [menu, setMenu] = createStore({
open: false,
@@ -308,12 +319,20 @@ export const SortableWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
- const local = createMemo(() => props.directory === props.project.worktree)
+ // Guard against `props.project` being transiently undefined during a
+ // server-switch cascade. The parent renders
+ // <For each={workspaces()}>{(dir) => <SortableWorkspace project={project()!} ... />}</For>
+ // where `project()` can flip to undefined while the enclosing <Show when={project()}>
+ // gate hasn't yet unmounted this child. Bootstrap's setStore can then fire
+ // these memos with stale props.
+ const local = createMemo(() => props.directory === (props.project?.worktree ?? ""))
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)
- return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
+ const projectId = props.project?.id
+ if (!projectId) return name
+ return props.ctx.workspaceName(props.directory, projectId, branch) ?? name
})
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
@@ -344,7 +363,7 @@ export const SortableWorkspace = (props: {
InlineEditor={props.ctx.InlineEditor}
renameWorkspace={props.ctx.renameWorkspace}
setEditor={props.ctx.setEditor}
- projectId={props.project.id}
+ projectId={props.project?.id ?? ""}
/>
)
@@ -413,7 +432,7 @@ export const SortableWorkspace = (props: {
openEditor={props.ctx.openEditor}
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
- root={props.project.worktree}
+ root={props.project?.worktree ?? props.directory}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)}
/>
@@ -447,20 +466,33 @@ export const LocalWorkspace = (props: {
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
+ // Same guard pattern as SortableWorkspace: the parent passes
+ // `project={project()!}` but `project()` can transiently flip to
+ // undefined during a server-switch cascade before this component
+ // unmounts, so every reactive memo reading props.project has to
+ // tolerate undefined.
+ const worktree = createMemo(() => props.project?.worktree ?? "")
const workspace = createMemo(() => {
- const [store, setStore] = globalSync.child(props.project.worktree)
+ const dir = worktree()
+ if (!dir) return undefined
+ const [store, setStore] = globalSync.child(dir)
return { store, setStore }
})
- const slug = createMemo(() => base64Encode(props.project.worktree))
- const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
- const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
+ const slug = createMemo(() => (worktree() ? base64Encode(worktree()) : ""))
+ const sessions = createMemo(() => {
+ const store = workspace()?.store
+ return store ? sortedRootSessions(store, props.sortNow()) : []
+ })
+ const booted = createMemo((prev) => prev || workspace()?.store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
- const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) }))
+ const query = useQuery(() => ({ ...loadSessionsQuery(worktree()) }))
const loading = createMemo(() => query.isPending && count() === 0)
- const hasMore = createMemo(() => workspace().store.sessionTotal > count())
+ const hasMore = createMemo(() => (workspace()?.store.sessionTotal ?? 0) > count())
const loadMore = async () => {
- workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
- await globalSync.project.loadSessions(props.project.worktree)
+ const dir = worktree()
+ if (!dir) return
+ workspace()?.setStore("limit", (limit) => (limit ?? 0) + 5)
+ await globalSync.project.loadSessions(dir)
}
return (
diff --git a/packages/app/src/utils/scoped-cache.test.ts b/packages/app/src/utils/scoped-cache.test.ts
index 0c6189dafe..26821134c8 100644
--- a/packages/app/src/utils/scoped-cache.test.ts
+++ b/packages/app/src/utils/scoped-cache.test.ts
@@ -24,7 +24,7 @@ describe("createScopedCache", () => {
expect(disposed).toEqual(["b"])
})
- test("disposes entries on delete and clear", () => {
+ test("disposes entries on delete and clear", async () => {
const disposed: string[] = []
const cache = createScopedCache((key) => ({ key }), {
dispose: (value) => disposed.push(value.key),
@@ -39,6 +39,9 @@ describe("createScopedCache", () => {
cache.clear()
expect(cache.peek("b")).toBeUndefined()
+ // clear() defers dispose to a microtask to avoid nested cleanNode cascades
+ // when called from inside an onCleanup; flush the queue before asserting.
+ await Promise.resolve()
expect(disposed).toEqual(["a", "b"])
})
diff --git a/packages/app/src/utils/scoped-cache.ts b/packages/app/src/utils/scoped-cache.ts
index 224c363c1e..7044cdf03c 100644
--- a/packages/app/src/utils/scoped-cache.ts
+++ b/packages/app/src/utils/scoped-cache.ts
@@ -89,10 +89,21 @@ export function createScopedCache<T>(createValue: (key: string) => T, options: S
}
const clear = () => {
- for (const [key, entry] of store) {
- dispose(key, entry)
- }
+ // Defer dispose() calls to a microtask. When clear() runs inside an
+ // onCleanup during a parent remount (e.g. context/file.tsx and
+ // context/comments.tsx both do this), synchronous dispose on cached
+ // createRoot entries starts a nested cleanNode cascade while the outer
+ // cascade is mid-traversal, corrupting solid-js's graph walk state and
+ // throwing `Cannot read properties of null (reading '1')` at
+ // chunk-*.js:992. Deferring lets the outer cleanup finish first.
+ const pending: Array<[string, Entry<T>]> = []
+ for (const entry of store) pending.push(entry)
store.clear()
+ if (pending.length && options.dispose) {
+ queueMicrotask(() => {
+ for (const [key, entry] of pending) dispose(key, entry)
+ })
+ }
}
return {
diff --git a/packages/app/src/utils/server-switch.tsx b/packages/app/src/utils/server-switch.tsx
new file mode 100644
index 0000000000..480990b184
--- /dev/null
+++ b/packages/app/src/utils/server-switch.tsx
@@ -0,0 +1,9 @@
+import { createSignal } from "solid-js"
+
+// Global flag used to paint a full-window splash overlay while a server
+// swap is in progress. ServerKey's keyed <Show> remount is a big
+// synchronous cascade (dispose + remount of the entire app subtree) that
+// can freeze the UI for several seconds; setting this true before the
+// swap and false after lets us render an overlay above the ServerKey
+// boundary so the freeze has visual feedback instead of looking stuck.
+export const [serverSwitching, setServerSwitching] = createSignal(false)
diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts
index d0e6c42b6c..267d6c6539 100644
--- a/packages/desktop-electron/electron.vite.config.ts
+++ b/packages/desktop-electron/electron.vite.config.ts
@@ -60,6 +60,13 @@ export default defineConfig({
plugins: [appPlugin],
publicDir: "../../../app/public",
root: "src/renderer",
+ server: {
+ host: "127.0.0.1",
+ strictPort: true,
+ hmr: {
+ host: "127.0.0.1",
+ },
+ },
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts
index 174da94a5d..eb0b260ea9 100644
--- a/packages/desktop-electron/src/main/apps.ts
+++ b/packages/desktop-electron/src/main/apps.ts
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { dirname, extname, join } from "node:path"
+import { resolveWslHome, runWslInDistro } from "./wsl"
export function checkAppExists(appName: string): boolean {
if (process.platform === "win32") return true
@@ -13,20 +14,17 @@ export function resolveAppPath(appName: string): string | null {
return resolveWindowsAppPath(appName)
}
-export function wslPath(path: string, mode: "windows" | "linux" | null): string {
+export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
if (process.platform !== "win32") return path
const flag = mode === "windows" ? "-w" : "-u"
try {
- if (path.startsWith("~")) {
- const suffix = path.slice(1)
- const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
- const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
- return output.toString().trim()
+ const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path
+ const output = await runWslInDistro(["wslpath", flag, resolved], distro)
+ if (output.code !== 0) {
+ throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
}
-
- const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
- return output.toString().trim()
+ return output.stdout.trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}
diff --git a/packages/desktop-electron/src/main/constants.ts b/packages/desktop-electron/src/main/constants.ts
index 1e21661c1a..9a6bb53c64 100644
--- a/packages/desktop-electron/src/main/constants.ts
+++ b/packages/desktop-electron/src/main/constants.ts
@@ -6,5 +6,6 @@ export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod
export const SETTINGS_STORE = "opencode.settings"
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
-export const WSL_ENABLED_KEY = "wslEnabled"
+export const WSL_SERVERS_KEY = "wslServers"
+export const LEGACY_LOCAL_SERVER_KEY = "localServer"
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts
index 946e01e325..87a87e7672 100644
--- a/packages/desktop-electron/src/main/index.ts
+++ b/packages/desktop-electron/src/main/index.ts
@@ -1,7 +1,6 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
-import { createServer } from "node:net"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
@@ -32,33 +31,54 @@ app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"))
const { autoUpdater } = pkg
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import type { InitStep, ServerReadyData, SqliteMigrationProgress } from "../preload/types"
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
-import { CHANNEL, UPDATER_ENABLED } from "./constants"
+import { CHANNEL, UPDATER_ENABLED, WSL_SERVERS_KEY } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
-import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
+import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
+import { store } from "./store"
+import { createWslServersController } from "./wsl-servers"
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
-import type { Server } from "virtual:opencode-server"
const initEmitter = new EventEmitter()
let initStep: InitStep = { phase: "server_waiting" }
let mainWindow: BrowserWindow | null = null
-let server: Server.Listener | null = null
+let server: { stop(): void } | null = null
const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
+void serverReady.promise.catch(() => undefined)
const logger = initLogging()
+const wslServers = createWslServersController(
+ app.getVersion(),
+ async (distro) => {
+ logger.log("spawning wsl sidecar", { distro })
+ return spawnWslSidecar(distro, {
+ onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
+ })
+ },
+ {
+ log: (message, meta) => logger.log(message, meta),
+ error: (message, meta) => logger.error(message, meta),
+ },
+)
logger.log("app starting", {
version: app.getVersion(),
packaged: app.isPackaged,
})
+logger.log("config paths", {
+ userData: app.getPath("userData"),
+ settingsStore: store.path,
+ wslServersKey: WSL_SERVERS_KEY,
+ wslServers: store.get(WSL_SERVERS_KEY) ?? null,
+})
setupApp()
@@ -66,6 +86,14 @@ function setupApp() {
ensureLoopbackNoProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
+ process.on("uncaughtException", (error) => {
+ logger.error("main process uncaught exception", error)
+ })
+
+ process.on("unhandledRejection", (reason) => {
+ logger.error("main process unhandled rejection", reason)
+ })
+
if (!app.requestSingleInstanceLock()) {
app.quit()
return
@@ -88,15 +116,18 @@ function setupApp() {
app.on("before-quit", () => {
killSidecar()
+ wslServers.stopAll()
})
app.on("will-quit", () => {
killSidecar()
+ wslServers.stopAll()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
+ wslServers.stopAll()
app.exit(0)
})
}
@@ -132,19 +163,38 @@ async function initialize() {
const sqliteDone = needsMigration ? defer<void>() : undefined
let overlay: BrowserWindow | null = null
- const port = await getSidecarPort()
+ const port = await allocatePort()
const hostname = "127.0.0.1"
const url = `http://${hostname}:${port}`
const password = randomUUID()
+ const key = "local:windows"
- logger.log("spawning sidecar", { url })
- const { listener, health } = await spawnLocalServer(hostname, port, password)
- server = listener
- serverReady.resolve({
+ logger.log("spawning windows sidecar", { url })
+ const startupData: ServerReadyData = {
url,
username: "opencode",
password,
- })
+ local: {
+ key,
+ url,
+ username: "opencode",
+ password,
+ },
+ }
+ let startupError: Error | null = null
+ const startup = await (async () => {
+ try {
+ return await spawnLocalServer(hostname, port, password)
+ } catch (error) {
+ startupError = asError(error)
+ logger.error("windows sidecar startup failed", startupError)
+ return undefined
+ }
+ })()
+ server = startup?.listener ?? null
+
+ // Initialize WSL sidecars in parallel; failures do not block app startup.
+ void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", asError(error)))
const loadingTask = (async () => {
logger.log("sidecar connection started", { url })
@@ -160,14 +210,24 @@ async function initialize() {
await sqliteDone?.promise
}
- await Promise.race([
- health.wait,
- delay(30_000).then(() => {
- throw new Error("Sidecar health check timed out")
- }),
- ]).catch((error) => {
- logger.error("sidecar health check failed", error)
- })
+ if (startup) {
+ await Promise.race([
+ startup.health.wait,
+ delay(30_000).then(() => {
+ throw new Error("Sidecar health check timed out")
+ }),
+ ])
+ .then(() => {
+ serverReady.resolve(startupData)
+ })
+ .catch((error) => {
+ startupError = asError(error)
+ logger.error("sidecar health check failed", startupError)
+ serverReady.reject(startupError)
+ })
+ } else {
+ serverReady.reject(startupError ?? new Error("Local server startup failed"))
+ }
logger.log("loading task finished")
})()
@@ -181,6 +241,7 @@ async function initialize() {
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
if (show) {
overlay = createLoadingWindow(globals)
+ wireWindowDiagnostics(overlay, "loading")
await delay(1_000)
}
}
@@ -193,11 +254,67 @@ async function initialize() {
}
mainWindow = createMainWindow(globals)
+ wireWindowDiagnostics(mainWindow, "main")
wireMenu()
overlay?.close()
}
+function wireWindowDiagnostics(win: BrowserWindow, label: string) {
+ win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
+ // Render `message` as a block so multi-line stack traces survive; the
+ // previous shape stuffed the message into a JSON object which escaped
+ // `\n` and made stacks unreadable.
+ const location = sourceId ? ` [${sourceId}:${line}]` : ""
+ const text = `${label} renderer${location}\n${message}`
+ if (level >= 3) {
+ logger.error(text)
+ return
+ }
+ if (level >= 2) {
+ logger.warn(text)
+ return
+ }
+ logger.log(text)
+ })
+
+ win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
+ logger.error(`${label} renderer failed load`, {
+ errorCode,
+ errorDescription,
+ validatedURL,
+ isMainFrame,
+ })
+ })
+
+ win.webContents.on("render-process-gone", (_event, details) => {
+ logger.error(`${label} renderer process gone`, details)
+ })
+
+ win.webContents.on("preload-error", (_event, path, error) => {
+ logger.error(`${label} preload error`, {
+ path,
+ error: error instanceof Error ? (error.stack ?? error.message) : String(error),
+ })
+ })
+
+ // DevTools accelerators on Windows/Linux where the menu isn't created.
+ win.webContents.on("before-input-event", (_event, input) => {
+ if (input.type !== "keyDown") return
+ const key = input.key
+ const toggle =
+ key === "F12" ||
+ (input.control && input.shift && (key === "I" || key === "i")) ||
+ (input.meta && input.alt && (key === "I" || key === "i"))
+ if (!toggle) return
+ win.webContents.toggleDevTools()
+ })
+
+ win.on("unresponsive", () => {
+ logger.error(`${label} window became unresponsive`)
+ })
+}
+
function wireMenu() {
if (!mainWindow) return
createMenu({
@@ -206,16 +323,13 @@ function wireMenu() {
void checkForUpdates(true)
},
reload: () => mainWindow?.reload(),
- relaunch: () => {
- killSidecar()
- app.relaunch()
- app.exit(0)
- },
+ relaunch: () => relaunchApp(),
})
}
registerIpcHandlers({
killSidecar: () => killSidecar(),
+ relaunch: () => relaunchApp(),
awaitInitialization: async (sendStep) => {
sendStep(initStep)
const listener = (step: InitStep) => sendStep(step)
@@ -229,15 +343,29 @@ registerIpcHandlers({
initEmitter.off("step", listener)
}
},
+ getWslServersState: () => wslServers.getState(),
+ onWslServersEvent: (listener) => wslServers.subscribe(listener),
+ wslServersProbeRuntime: () => wslServers.probeRuntime(),
+ wslServersRefreshDistros: () => wslServers.refreshDistros(),
+ wslServersInstallWsl: () => wslServers.installWsl(),
+ wslServersInstallDistro: (name) => wslServers.installDistro(name),
+ wslServersProbeDistro: (name) => wslServers.probeDistro(name),
+ wslServersProbeOpencode: (name) => wslServers.probeOpencode(name),
+ wslServersInstallOpencode: (name) => wslServers.installOpencode(name),
+ wslServersOpenTerminal: (name) => wslServers.openTerminal(name),
+ wslServersAddServer: (distro) => wslServers.addServer(distro),
+ wslServersRemoveServer: (id) => wslServers.removeServer(id),
+ wslServersStartServer: (id) => wslServers.startServer(id),
+ wslServersStopServer: (id) => wslServers.stopServer(id),
+ wslServersCancelJob: () => wslServers.cancelJob(),
+ wslServersUpdateAcknowledgements: (id, acks) => wslServers.updateAcknowledgements(id, acks),
getDefaultServerUrl: () => getDefaultServerUrl(),
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
- getWslConfig: () => Promise.resolve(getWslConfig()),
- setWslConfig: (config: WslConfig) => setWslConfig(config),
getDisplayBackend: async () => null,
setDisplayBackend: async () => undefined,
parseMarkdown: async (markdown) => parseMarkdown(markdown),
checkAppExists: async (appName) => checkAppExists(appName),
- wslPath: async (path, mode) => wslPath(path, mode),
+ wslPath: async (path, mode, distro) => wslPath(path, mode, distro),
resolveAppPath: async (appName) => resolveAppPath(appName),
loadingWindowComplete: () => loadingComplete.resolve(),
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
@@ -252,6 +380,15 @@ function killSidecar() {
server = null
}
+function relaunchApp() {
+ // app.exit() skips before-quit / will-quit, so relaunch callers must
+ // explicitly stop sidecars here rather than relying on process hooks.
+ killSidecar()
+ wslServers.stopAll()
+ app.relaunch()
+ app.exit(0)
+}
+
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {
@@ -272,29 +409,6 @@ function ensureLoopbackNoProxy() {
upsert("no_proxy")
}
-async function getSidecarPort() {
- const fromEnv = process.env.OPENCODE_PORT
- if (fromEnv) {
- const parsed = Number.parseInt(fromEnv, 10)
- if (!Number.isNaN(parsed)) return parsed
- }
-
- return await new Promise<number>((resolve, reject) => {
- const server = createServer()
- server.on("error", reject)
- server.listen(0, "127.0.0.1", () => {
- const address = server.address()
- if (typeof address !== "object" || !address) {
- server.close()
- reject(new Error("Failed to get port"))
- return
- }
- const port = address.port
- server.close(() => resolve(port))
- })
- })
-}
-
function sqliteFileExists() {
const xdg = process.env.XDG_DATA_HOME
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
@@ -358,6 +472,7 @@ async function checkUpdate() {
async function installUpdate() {
if (!updateReady) return
killSidecar()
+ wslServers.stopAll()
autoUpdater.quitAndInstall()
}
@@ -408,6 +523,10 @@ function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+function asError(error: unknown) {
+ return error instanceof Error ? error : new Error(String(error))
+}
+
function defer<T>() {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts
index 52d87ed7ee..c6d2c4face 100644
--- a/packages/desktop-electron/src/main/ipc.ts
+++ b/packages/desktop-electron/src/main/ipc.ts
@@ -2,7 +2,16 @@ import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types"
+import type {
+ InitStep,
+ ServerReadyData,
+ SqliteMigrationProgress,
+ TitlebarTheme,
+ WslServerAcknowledgements,
+ WslServerConfig,
+ WslServersEvent,
+ WslServersState,
+} from "../preload/types"
import { getStore } from "./store"
import { setTitlebar } from "./windows"
@@ -13,16 +22,31 @@ const pickerFilters = (ext?: string[]) => {
type Deps = {
killSidecar: () => void
+ relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
+ getWslServersState: () => Promise<WslServersState> | WslServersState
+ onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void
+ wslServersProbeRuntime: () => Promise<void> | void
+ wslServersRefreshDistros: () => Promise<void> | void
+ wslServersInstallWsl: () => Promise<void> | void
+ wslServersInstallDistro: (name: string) => Promise<void> | void
+ wslServersProbeDistro: (name: string) => Promise<void> | void
+ wslServersProbeOpencode: (name: string) => Promise<void> | void
+ wslServersInstallOpencode: (name: string) => Promise<void> | void
+ wslServersOpenTerminal: (name: string) => Promise<void> | void
+ wslServersAddServer: (distro: string) => Promise<WslServerConfig> | WslServerConfig
+ wslServersRemoveServer: (id: string) => Promise<void> | void
+ wslServersStartServer: (id: string) => Promise<void> | void
+ wslServersStopServer: (id: string) => Promise<void> | void
+ wslServersCancelJob: () => Promise<void> | void
+ wslServersUpdateAcknowledgements: (id: string, acks: Partial<WslServerAcknowledgements>) => Promise<void> | void
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
- getWslConfig: () => Promise<WslConfig>
- setWslConfig: (config: WslConfig) => Promise<void> | void
getDisplayBackend: () => Promise<string | null>
setDisplayBackend: (backend: string | null) => Promise<void> | void
parseMarkdown: (markdown: string) => Promise<string> | string
checkAppExists: (appName: string) => Promise<boolean> | boolean
- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
+ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> | void
@@ -32,25 +56,62 @@ type Deps = {
}
export function registerIpcHandlers(deps: Deps) {
+ const offWslServers = deps.onWslServersEvent((payload) => {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (win.isDestroyed()) continue
+ win.webContents.send("wsl-servers-event", payload)
+ }
+ })
+ app.once("will-quit", offWslServers)
+
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)
return deps.awaitInitialization(send)
})
+ ipcMain.handle("wsl-servers-get-state", () => deps.getWslServersState())
+ ipcMain.handle("wsl-servers-probe-runtime", () => deps.wslServersProbeRuntime())
+ ipcMain.handle("wsl-servers-refresh-distros", () => deps.wslServersRefreshDistros())
+ ipcMain.handle("wsl-servers-install-wsl", () => deps.wslServersInstallWsl())
+ ipcMain.handle("wsl-servers-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
+ deps.wslServersInstallDistro(name),
+ )
+ ipcMain.handle("wsl-servers-probe-distro", (_event: IpcMainInvokeEvent, name: string) =>
+ deps.wslServersProbeDistro(name),
+ )
+ ipcMain.handle("wsl-servers-probe-opencode", (_event: IpcMainInvokeEvent, name: string) =>
+ deps.wslServersProbeOpencode(name),
+ )
+ ipcMain.handle("wsl-servers-install-opencode", (_event: IpcMainInvokeEvent, name: string) =>
+ deps.wslServersInstallOpencode(name),
+ )
+ ipcMain.handle("wsl-servers-open-terminal", (_event: IpcMainInvokeEvent, name: string) =>
+ deps.wslServersOpenTerminal(name),
+ )
+ ipcMain.handle("wsl-servers-add", (_event: IpcMainInvokeEvent, distro: string) => deps.wslServersAddServer(distro))
+ ipcMain.handle("wsl-servers-remove", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersRemoveServer(id))
+ ipcMain.handle("wsl-servers-start", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStartServer(id))
+ ipcMain.handle("wsl-servers-stop", (_event: IpcMainInvokeEvent, id: string) => deps.wslServersStopServer(id))
+ ipcMain.handle("wsl-servers-cancel", () => deps.wslServersCancelJob())
+ ipcMain.handle(
+ "wsl-servers-update-acknowledgements",
+ (_event: IpcMainInvokeEvent, id: string, acks: Partial<WslServerAcknowledgements>) =>
+ deps.wslServersUpdateAcknowledgements(id, acks),
+ )
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
deps.setDefaultServerUrl(url),
)
- ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
- ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
deps.setDisplayBackend(backend),
)
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
- ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
- deps.wslPath(path, mode),
+ ipcMain.handle(
+ "wsl-path",
+ (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null, distro?: string | null) =>
+ deps.wslPath(path, mode, distro),
)
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
@@ -167,8 +228,7 @@ export function registerIpcHandlers(deps: Deps) {
})
ipcMain.on("relaunch", () => {
- app.relaunch()
- app.exit(0)
+ deps.relaunch()
})
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts
index fcf209fb67..f55554a8eb 100644
--- a/packages/desktop-electron/src/main/menu.ts
+++ b/packages/desktop-electron/src/main/menu.ts
@@ -75,9 +75,9 @@ export function createMenu(deps: Deps) {
{ role: "reload" },
{ role: "toggleDevTools" },
{ type: "separator" },
- { role: "resetZoom" },
- { role: "zoomIn" },
- { role: "zoomOut" },
+ { label: "Actual Size", accelerator: "Cmd+0", click: () => deps.trigger("zoom.reset") },
+ { label: "Zoom In", accelerator: "Cmd+=", click: () => deps.trigger("zoom.in") },
+ { label: "Zoom Out", accelerator: "Cmd+-", click: () => deps.trigger("zoom.out") },
{ type: "separator" },
{ role: "togglefullscreen" },
],
diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts
index 5a6050013a..ffb1d2e262 100644
--- a/packages/desktop-electron/src/main/server.ts
+++ b/packages/desktop-electron/src/main/server.ts
@@ -1,9 +1,11 @@
+import { spawn } from "node:child_process"
+import { randomUUID } from "node:crypto"
+import { createServer } from "node:net"
import { app } from "electron"
-import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
+import { DEFAULT_SERVER_URL_KEY } from "./constants"
import { getUserShell, loadShellEnv } from "./shell-env"
import { store } from "./store"
-
-export type WslConfig = { enabled: boolean }
+import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
export type HealthCheck = { wait: Promise<void> }
@@ -21,13 +23,26 @@ export function setDefaultServerUrl(url: string | null) {
store.delete(DEFAULT_SERVER_URL_KEY)
}
-export function getWslConfig(): WslConfig {
- const value = store.get(WSL_ENABLED_KEY)
- return { enabled: typeof value === "boolean" ? value : false }
-}
-
-export function setWslConfig(config: WslConfig) {
- store.set(WSL_ENABLED_KEY, config.enabled)
+export async function allocatePort() {
+ const fromEnv = process.env.OPENCODE_PORT
+ if (fromEnv) {
+ const parsed = Number.parseInt(fromEnv, 10)
+ if (!Number.isNaN(parsed)) return parsed
+ }
+ return new Promise<number>((resolve, reject) => {
+ const server = createServer()
+ server.on("error", reject)
+ server.listen(0, "127.0.0.1", () => {
+ const address = server.address()
+ if (typeof address !== "object" || !address) {
+ server.close()
+ reject(new Error("Failed to get port"))
+ return
+ }
+ const port = address.port
+ server.close(() => resolve(port))
+ })
+ })
}
export async function spawnLocalServer(hostname: string, port: number, password: string) {
@@ -57,6 +72,107 @@ export async function spawnLocalServer(hostname: string, port: number, password:
return { listener, health: { wait } }
}
+export type WslSidecar = {
+ listener: { stop: () => void }
+ url: string
+ username: string | null
+ password: string
+}
+
+export async function spawnWslSidecar(
+ distro: string,
+ opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
+): Promise<WslSidecar> {
+ // Every wsl.exe invocation below goes through wslArgs which injects
+ // `--user root`. That matters even when a distro has DefaultUid=0
+ // (i.e. the interactive first-run user account setup never ran):
+ // explicit --user root bypasses the OOBE hook that would otherwise
+ // prompt on stdin, so we can resolve opencode and spawn the sidecar
+ // without any machine-wide first-run handshake. The earlier Ubuntu
+ // hang was caused by invoking without --user (default uid 0 triggers
+ // OOBE), not by the registry state itself. We still have a 20s
+ // timeout in runCommand as a safety net for true wsl.exe wedges.
+ const opencode = await resolveWslOpencode(distro)
+ if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`)
+
+ const port = await allocatePort()
+ const password = randomUUID()
+ const username = "opencode"
+
+ const script = [
+ "set -euo pipefail",
+ "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
+ "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
+ "export OPENCODE_CLIENT=desktop",
+ `export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
+ `export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
+ 'export XDG_STATE_HOME="$HOME/.local/state"',
+ `exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
+ ].join("\n")
+
+ const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
+ stdio: ["pipe", "pipe", "pipe"],
+ windowsHide: true,
+ })
+ child.stdin.end(script)
+
+ let settled = false
+ const recentOutput: string[] = []
+ const emit = (line: WslCommandLine) => {
+ if (settled || !line.text.trim()) return
+ recentOutput.push(`[${line.stream}] ${line.text}`)
+ if (recentOutput.length > 12) recentOutput.shift()
+ opts.onLine?.(line)
+ }
+
+ forwardLines(child.stdout, "stdout", emit)
+ forwardLines(child.stderr, "stderr", emit)
+
+ const exit = new Promise<never>((_, reject) => {
+ child.once("error", reject)
+ child.once("exit", (code, signal) => {
+ reject(new Error(startupFailure(code, signal, recentOutput)))
+ })
+ })
+
+ const url = `http://127.0.0.1:${port}`
+ const healthPromise = (async () => {
+ while (true) {
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ if (await checkHealth(url, password)) return
+ }
+ })()
+
+ const timeoutMs = opts.healthTimeoutMs ?? 30_000
+ const timeout = new Promise<never>((_, reject) => {
+ const id = setTimeout(
+ () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)),
+ timeoutMs,
+ )
+ void healthPromise.finally(() => clearTimeout(id))
+ })
+
+ try {
+ await Promise.race([healthPromise, exit, timeout])
+ } catch (error) {
+ child.kill()
+ throw error
+ } finally {
+ settled = true
+ }
+
+ return {
+ listener: {
+ stop() {
+ child.kill()
+ },
+ },
+ url,
+ username,
+ password,
+ }
+}
+
function prepareServerEnv(password: string) {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
@@ -73,6 +189,33 @@ function prepareServerEnv(password: string) {
Object.assign(process.env, env)
}
+function shellEscape(value: string) {
+ return `'${value.replace(/'/g, `'"'"'`)}'`
+}
+
+function forwardLines(
+ stream: NodeJS.ReadableStream,
+ source: WslCommandLine["stream"],
+ onLine: (line: WslCommandLine) => void,
+) {
+ let pending = ""
+ stream.setEncoding("utf8")
+ stream.on("data", (chunk: string) => {
+ pending += chunk
+ const lines = pending.split(/\r?\n/g)
+ pending = lines.pop() ?? ""
+ for (const line of lines) onLine({ stream: source, text: line })
+ })
+ stream.on("end", () => {
+ if (pending) onLine({ stream: source, text: pending })
+ })
+}
+
+function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) {
+ const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : ""
+ return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}`
+}
+
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
let healthUrl: URL
try {
diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts
index 95f80c1240..26f138f5fb 100644
--- a/packages/desktop-electron/src/main/windows.ts
+++ b/packages/desktop-electron/src/main/windows.ts
@@ -134,7 +134,9 @@ export function createLoadingWindow(globals: Globals) {
function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
- const url = new URL(html, devUrl)
+ const base = new URL(devUrl)
+ if (base.hostname === "localhost") base.hostname = "127.0.0.1"
+ const url = new URL(html, base)
void win.loadURL(url.toString())
return
}
@@ -157,7 +159,9 @@ function injectGlobals(win: BrowserWindow, globals: Globals) {
function wireZoom(win: BrowserWindow) {
win.webContents.setZoomFactor(1)
- win.webContents.on("zoom-changed", () => {
- win.webContents.setZoomFactor(1)
- })
+ // Disable Chromium's touch/pinch zoom. Keyboard and wheel zoom are handled
+ // in the renderer so the Solid `webviewZoom` signal stays the single source
+ // of truth; a stray `zoom-changed` handler here would race with the renderer
+ // and intermittently snap the factor back to 1.
+ void win.webContents.setVisualZoomLevelLimits(1, 1).catch(() => undefined)
}
diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts
new file mode 100644
index 0000000000..c35e4e52bf
--- /dev/null
+++ b/packages/desktop-electron/src/main/wsl-servers.ts
@@ -0,0 +1,522 @@
+import type {
+ WslDistroProbe,
+ WslInstalledDistro,
+ WslJob,
+ WslOnlineDistro,
+ WslOpencodeCheck,
+ WslRuntimeCheck,
+ WslServerAcknowledgements,
+ WslServerConfig,
+ WslServerItem,
+ WslServerRuntime,
+ WslServersEvent,
+ WslServersState,
+ WslTranscriptLine,
+} from "../preload/types"
+import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
+import { spawnWslSidecar } from "./server"
+import { store } from "./store"
+import type { WslCommandLine } from "./wsl"
+import {
+ installWslDistro,
+ installWslOpencode,
+ installWslRuntimeElevated,
+ listInstalledWslDistros,
+ listOnlineWslDistros,
+ openWslTerminal,
+ probeWslDistro,
+ probeWslRuntime,
+ readWslCommandVersion,
+ resolveWslOpencode,
+ upgradeWslOpencode,
+ wslNeedsRestart,
+} from "./wsl"
+
+type RunningSidecar = {
+ listener: { stop: () => void }
+ url: string
+ username: string | null
+ password: string
+}
+
+type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
+
+type ControllerLogger = {
+ log: (message: string, meta?: unknown) => void
+ error: (message: string, meta?: unknown) => void
+}
+
+export type WslServersController = ReturnType<typeof createWslServersController>
+
+export function wslServerIdForDistro(distro: string) {
+ return `wsl:${distro}`
+}
+
+export function createWslServersController(appVersion: string, spawnSidecar: SpawnSidecar, logger?: ControllerLogger) {
+ const mainLogger: ControllerLogger | undefined = logger
+ let state: WslServersState = initialState()
+ const listeners = new Set<(event: WslServersEvent) => void>()
+ const sidecars = new Map<string, RunningSidecar>()
+ const startAttempts = new Map<string, number>()
+ let jobAbort: AbortController | undefined
+
+ const emit = () => {
+ for (const listener of listeners) listener({ type: "state", state })
+ }
+
+ const setState = (next: Partial<WslServersState>) => {
+ state = { ...state, ...next }
+ emit()
+ }
+
+ const appendTranscript = (line: Omit<WslTranscriptLine, "at">) => {
+ setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] })
+ }
+
+ const clearTranscript = () => setState({ transcript: [] })
+
+ const persistServers = (servers: WslServerConfig[]) => {
+ store.set(WSL_SERVERS_KEY, { servers })
+ }
+
+ const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
+ const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
+ setState({ servers: next })
+ }
+
+ const beginJob = (job: WslJob, opts: { keepTranscript?: boolean } = {}): AbortController => {
+ jobAbort?.abort()
+ const abort = new AbortController()
+ jobAbort = abort
+ if (!opts.keepTranscript) clearTranscript()
+ setState({ job, lastError: null })
+ return abort
+ }
+
+ const endJob = (abort: AbortController, error?: Error | null) => {
+ if (jobAbort !== abort) return
+ jobAbort = undefined
+ setState({ job: null, lastError: error?.message ?? null })
+ }
+
+ const onLine = (line: WslCommandLine) => appendTranscript(line)
+
+ const refreshFromStore = () => {
+ const persisted = readPersistedServers()
+ const items: WslServerItem[] = persisted.map((config) => {
+ const existing = state.servers.find((item) => item.config.id === config.id)
+ return {
+ config,
+ runtime: existing?.runtime ?? { kind: "stopped" },
+ }
+ })
+ setState({ servers: items })
+ }
+
+ const setRuntime = (id: string, runtime: WslServerRuntime) => {
+ updateServer(id, (item) => ({ ...item, runtime }))
+ }
+
+ const nextStartAttempt = (id: string) => {
+ const next = (startAttempts.get(id) ?? 0) + 1
+ startAttempts.set(id, next)
+ return next
+ }
+
+ const invalidateStartAttempt = (id: string) => {
+ startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
+ }
+
+ const isCurrentStartAttempt = (id: string, attempt: number) => {
+ return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
+ }
+
+ const startServer = async (id: string) => {
+ const item = state.servers.find((x) => x.config.id === id)
+ if (!item) return
+ const attempt = nextStartAttempt(id)
+ await stopServerInternal(id)
+ if (!isCurrentStartAttempt(id, attempt)) return
+ setRuntime(id, { kind: "starting" })
+ mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro })
+ try {
+ const sidecar = await spawnSidecar(item.config.distro)
+ if (!isCurrentStartAttempt(id, attempt)) {
+ try {
+ sidecar.listener.stop()
+ } catch {
+ // ignore stop errors for stale sidecars
+ }
+ return
+ }
+ sidecars.set(id, sidecar)
+ setRuntime(id, {
+ kind: "ready",
+ url: sidecar.url,
+ username: sidecar.username,
+ password: sidecar.password,
+ })
+ mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ if (!isCurrentStartAttempt(id, attempt)) return
+ setRuntime(id, { kind: "failed", message })
+ // Without this, an Ubuntu-style silent failure leaves no trace in
+ // main.log — the controller captures the message in its state but
+ // nothing surfaces unless the user opens the WSL servers dialog.
+ mainLogger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
+ }
+ }
+
+ const stopServerInternal = async (id: string) => {
+ const existing = sidecars.get(id)
+ if (!existing) return
+ try {
+ existing.listener.stop()
+ } catch {
+ // ignore stop errors
+ }
+ sidecars.delete(id)
+ }
+
+ const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
+ const abort = beginJob(job)
+ try {
+ const value = await runner(abort)
+ endJob(abort)
+ return value
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ endJob(abort)
+ return undefined
+ }
+ const err = error instanceof Error ? error : new Error(String(error))
+ endJob(abort, err)
+ throw err
+ }
+ }
+
+ return {
+ getState() {
+ return state
+ },
+ subscribe(listener: (event: WslServersEvent) => void) {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+
+ async initialize() {
+ refreshFromStore()
+ await Promise.all(state.servers.map((item) => startServer(item.config.id)))
+ },
+
+ async probeRuntime() {
+ await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: "Checking WSL runtime" })
+ const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
+ setState({
+ runtime,
+ pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
+ })
+ })
+ },
+
+ async refreshDistros() {
+ await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: "Listing WSL distros" })
+ const [installedResult, onlineResult] = await Promise.allSettled([
+ listInstalledWslDistros({ signal: abort.signal, onLine }),
+ listOnlineWslDistros({ signal: abort.signal, onLine }),
+ ])
+ const installed = installedResult.status === "fulfilled" ? installedResult.value : []
+ const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
+ setState({ installed, online })
+ })
+ },
+
+ async installWsl() {
+ await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: "Installing WSL runtime" })
+ const result = await installWslRuntimeElevated({ signal: abort.signal, onLine })
+ if (result.code !== 0) {
+ const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
+ throw new Error(message)
+ }
+ const pendingRestart = wslNeedsRestart(result)
+ setState({ pendingRestart })
+ if (!pendingRestart) {
+ const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
+ setState({ runtime })
+ }
+ })
+ },
+
+ async installDistro(name: string) {
+ await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` })
+ const result = await installWslDistro(name, { signal: abort.signal, onLine })
+ if (result.code !== 0) {
+ const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
+ throw new Error(message)
+ }
+ const [installedResult, onlineResult] = await Promise.allSettled([
+ listInstalledWslDistros({ signal: abort.signal, onLine }),
+ listOnlineWslDistros({ signal: abort.signal, onLine }),
+ ])
+ const installed = installedResult.status === "fulfilled" ? installedResult.value : []
+ const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
+ const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
+ setState({
+ installed,
+ online,
+ distroProbes: { ...state.distroProbes, [name]: probe },
+ })
+ })
+ },
+
+ async probeDistro(name: string) {
+ await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: `Checking ${name}` })
+ const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
+ setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
+ })
+ },
+
+ async probeOpencode(name: string) {
+ await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` })
+ const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
+ const version = resolved ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) : null
+ setState({
+ opencodeChecks: {
+ ...state.opencodeChecks,
+ [name]: opencodeCheck(name, resolved, version, appVersion),
+ },
+ })
+ })
+ },
+
+ async installOpencode(name: string) {
+ await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
+ appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` })
+ const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
+ const existingVersion = resolved
+ ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine })
+ : null
+ const result =
+ resolved && existingVersion
+ ? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine })
+ : await installWslOpencode(appVersion, name, { signal: abort.signal, onLine })
+ if (result.code !== 0) {
+ throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
+ }
+ const nextPath = await resolveWslOpencode(name, { signal: abort.signal, onLine })
+ const nextVersion = nextPath
+ ? await readWslCommandVersion(nextPath, name, { signal: abort.signal, onLine })
+ : null
+ setState({
+ opencodeChecks: {
+ ...state.opencodeChecks,
+ [name]: opencodeCheck(name, nextPath, nextVersion, appVersion),
+ },
+ })
+ })
+ },
+
+ async openTerminal(name: string) {
+ await openWslTerminal(name)
+ },
+
+ async cancelJob() {
+ jobAbort?.abort()
+ jobAbort = undefined
+ appendTranscript({ stream: "system", text: "Canceled" })
+ setState({ job: null })
+ },
+
+ async addServer(distro: string): Promise<WslServerConfig> {
+ const id = wslServerIdForDistro(distro)
+ if (state.servers.some((item) => item.config.id === id)) {
+ throw new Error(`${distro} is already added`)
+ }
+ const config: WslServerConfig = {
+ id,
+ distro,
+ acknowledgements: { root: false, mismatch: null },
+ }
+ persistServers([...readPersistedServers(), config])
+ setState({
+ servers: [...state.servers, { config, runtime: { kind: "starting" } }],
+ })
+ void startServer(id)
+ return config
+ },
+
+ async removeServer(id: string) {
+ invalidateStartAttempt(id)
+ await stopServerInternal(id)
+ const remaining = readPersistedServers().filter((item) => item.id !== id)
+ persistServers(remaining)
+ setState({ servers: state.servers.filter((item) => item.config.id !== id) })
+ },
+
+ startServer,
+
+ async stopServer(id: string) {
+ invalidateStartAttempt(id)
+ await stopServerInternal(id)
+ setRuntime(id, { kind: "stopped" })
+ },
+
+ async updateAcknowledgements(id: string, acks: Partial<WslServerAcknowledgements>) {
+ const persisted = readPersistedServers()
+ const next = persisted.map((config) =>
+ config.id === id ? { ...config, acknowledgements: { ...config.acknowledgements, ...acks } } : config,
+ )
+ persistServers(next)
+ refreshFromStore()
+ },
+
+ stopAll() {
+ for (const item of state.servers) invalidateStartAttempt(item.config.id)
+ for (const [id] of sidecars) {
+ const existing = sidecars.get(id)
+ try {
+ existing?.listener.stop()
+ } catch {
+ // ignore
+ }
+ }
+ sidecars.clear()
+ },
+ }
+}
+
+function initialState(): WslServersState {
+ return {
+ runtime: null,
+ installed: [],
+ online: [],
+ distroProbes: {},
+ opencodeChecks: {},
+ pendingRestart: false,
+ servers: [],
+ job: null,
+ transcript: [],
+ lastError: null,
+ }
+}
+
+function readPersistedServers(): WslServerConfig[] {
+ const existing = store.get(WSL_SERVERS_KEY)
+ if (existing && typeof existing === "object") {
+ const record = existing as { servers?: unknown }
+ const list = Array.isArray(record.servers) ? record.servers : []
+ return list.flatMap(normalizePersistedServer)
+ }
+ const migrated = migrateLegacyLocalServer()
+ if (migrated.length) store.set(WSL_SERVERS_KEY, { servers: migrated })
+ return migrated
+}
+
+function migrateLegacyLocalServer(): WslServerConfig[] {
+ const legacy = store.get(LEGACY_LOCAL_SERVER_KEY)
+ if (!legacy || typeof legacy !== "object") return []
+ const record = legacy as Record<string, unknown>
+ if (record.mode !== "wsl") return []
+ const distro = typeof record.distro === "string" ? record.distro : null
+ if (!distro) return []
+ return [
+ {
+ id: wslServerIdForDistro(distro),
+ distro,
+ acknowledgements: { root: false, mismatch: null },
+ },
+ ]
+}
+
+function normalizePersistedServer(value: unknown): WslServerConfig[] {
+ if (!value || typeof value !== "object") return []
+ const record = value as Record<string, unknown>
+ const distro = typeof record.distro === "string" && record.distro.length > 0 ? record.distro : null
+ if (!distro) return []
+ const id = typeof record.id === "string" && record.id.length > 0 ? record.id : wslServerIdForDistro(distro)
+ return [
+ {
+ id,
+ distro,
+ acknowledgements: normalizeAcks(record.acknowledgements),
+ },
+ ]
+}
+
+function normalizeAcks(value: unknown): WslServerAcknowledgements {
+ const record = value && typeof value === "object" ? (value as Record<string, unknown>) : {}
+ const mismatch =
+ record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record<string, unknown>) : null
+ return {
+ root: record.root === true,
+ mismatch:
+ mismatch && typeof mismatch.path === "string" && typeof mismatch.version === "string"
+ ? { path: mismatch.path, version: mismatch.version }
+ : null,
+ }
+}
+
+function opencodeCheck(
+ distro: string,
+ resolvedPath: string | null,
+ version: string | null,
+ expectedVersion: string,
+): WslOpencodeCheck {
+ if (!resolvedPath) {
+ return {
+ distro,
+ resolvedPath: null,
+ version: null,
+ expectedVersion,
+ matchesDesktop: null,
+ error: "opencode is not installed in this distro",
+ }
+ }
+ if (!version) {
+ return {
+ distro,
+ resolvedPath,
+ version: null,
+ expectedVersion,
+ matchesDesktop: null,
+ error: "opencode is installed but could not run",
+ }
+ }
+ return {
+ distro,
+ resolvedPath,
+ version,
+ expectedVersion,
+ matchesDesktop: version === expectedVersion,
+ error: null,
+ }
+}
+
+function summarize(value: string) {
+ return value
+ .split(/\r?\n/g)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .join("\n")
+}
+
+// Re-export types used by callers
+export type {
+ WslInstalledDistro,
+ WslOnlineDistro,
+ WslRuntimeCheck,
+ WslDistroProbe,
+ WslOpencodeCheck,
+ WslServerConfig,
+ WslServerItem,
+ WslServerRuntime,
+ WslServersEvent,
+ WslServersState,
+}
diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts
new file mode 100644
index 0000000000..07a22b8252
--- /dev/null
+++ b/packages/desktop-electron/src/main/wsl.ts
@@ -0,0 +1,491 @@
+import { spawn } from "node:child_process"
+import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
+
+export type WslCommandLine = {
+ stream: "stdout" | "stderr"
+ text: string
+}
+
+export type WslCommandResult = {
+ code: number | null
+ signal: NodeJS.Signals | null
+ stdout: string
+ stderr: string
+}
+
+type RunWslOptions = {
+ onLine?: (line: WslCommandLine) => void
+ signal?: AbortSignal
+ /**
+ * Ceiling on how long we wait for the child process to exit. When the
+ * LXSS service or a specific distro wedges (e.g. Ubuntu-24.04 with a
+ * pending first-run prompt), `wsl.exe` never returns and any command
+ * that doesn't specify a timeout hangs the entire startup flow. Default
+ * is 20s — enough for slow cold-starts, short enough to fail fast on
+ * a wedge. Callers can override for longer-running jobs.
+ */
+ timeoutMs?: number
+}
+
+const DEFAULT_WSL_TIMEOUT_MS = 20_000
+
+// `--user root` bypasses the distro's default-user requirement. A freshly
+// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively
+// for a username/password on its first invocation; when spawned with
+// piped stdio that prompt blocks forever or silently reads garbage,
+// leaving the sidecar hanging and the server unhealthy. Running as root
+// sidesteps the entire first-run setup flow — opencode only needs an
+// HTTP listener in the distro, not a per-user environment, so root is
+// a safe default for the sidecar process.
+export function wslArgs(args: string[], distro?: string | null) {
+ if (distro) return ["-d", distro, "--user", "root", "--", ...args]
+ return ["--user", "root", "--", ...args]
+}
+
+export function runWsl(args: string[], opts: RunWslOptions = {}) {
+ return runCommand("wsl", args, opts)
+}
+
+function runPowerShell(command: string, opts: RunWslOptions = {}) {
+ return runCommand(
+ "powershell.exe",
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
+ opts,
+ )
+}
+
+function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
+ return new Promise<WslCommandResult>((resolve, reject) => {
+ const child = spawn(command, args, {
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true,
+ signal: opts.signal,
+ })
+
+ // Guard every wsl.exe invocation with a timeout. When the distro or
+ // the LXSS service is wedged (Ubuntu first-run state, Windows update
+ // pending, etc.) wsl.exe produces no output and never exits; without
+ // this the whole sidecar spawn flow stalls the app forever.
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_WSL_TIMEOUT_MS
+ const timeoutId = setTimeout(() => {
+ try {
+ child.kill()
+ } catch {
+ /* ignore */
+ }
+ reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
+ }, timeoutMs)
+
+ let stdout = ""
+ let stderr = ""
+ let stdoutPending = ""
+ let stderrPending = ""
+ const stdoutDecoder = createOutputDecoder()
+ const stderrDecoder = createOutputDecoder()
+
+ const flush = (stream: WslCommandLine["stream"], pending: string) => {
+ if (!pending) return ""
+ opts.onLine?.({ stream, text: pending })
+ return ""
+ }
+
+ const append = (stream: WslCommandLine["stream"], chunk: string) => {
+ if (!chunk) return
+ if (stream === "stdout") {
+ stdout += chunk
+ stdoutPending += chunk
+ const lines = stdoutPending.split(/\r?\n/g)
+ stdoutPending = lines.pop() ?? ""
+ for (const line of lines) opts.onLine?.({ stream: "stdout", text: line })
+ return
+ }
+ stderr += chunk
+ stderrPending += chunk
+ const lines = stderrPending.split(/\r?\n/g)
+ stderrPending = lines.pop() ?? ""
+ for (const line of lines) opts.onLine?.({ stream: "stderr", text: line })
+ }
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ append("stdout", stdoutDecoder.decode(chunk))
+ })
+ child.stdout.on("end", () => {
+ append("stdout", stdoutDecoder.flush())
+ stdoutPending = flush("stdout", stdoutPending)
+ })
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ append("stderr", stderrDecoder.decode(chunk))
+ })
+ child.stderr.on("end", () => {
+ append("stderr", stderrDecoder.flush())
+ stderrPending = flush("stderr", stderrPending)
+ })
+
+ child.once("error", (error) => {
+ clearTimeout(timeoutId)
+ reject(error)
+ })
+ child.once("close", (code, signal) => {
+ clearTimeout(timeoutId)
+ resolve({ code, signal, stdout, stderr })
+ })
+ })
+}
+
+function createOutputDecoder() {
+ let decoder: TextDecoder | undefined
+ return {
+ decode(chunk: Buffer) {
+ decoder ??= new TextDecoder(detectOutputEncoding(chunk))
+ return decoder.decode(chunk, { stream: true })
+ },
+ flush() {
+ return decoder?.decode() ?? ""
+ },
+ }
+}
+
+function detectOutputEncoding(chunk: Uint8Array) {
+ if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
+ const pairs = Math.floor(chunk.length / 2)
+ if (pairs < 2) return "utf-8"
+ const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
+ const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
+ return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
+}
+
+export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
+ return runWsl(wslArgs(args, distro), opts)
+}
+
+export type WslRegistryDistro = {
+ name: string
+ defaultUid: number
+ state: number
+ version: number
+}
+
+// Distros that are designed to run as root and don't have a user-level
+// first-run setup. Ubuntu/Debian/Kali/etc. all run a first-boot hook that
+// prompts for a UNIX username on first invocation; if that never runs,
+// wsl.exe -d <distro> hangs silently forever.
+const ALWAYS_ROOT_DISTROS = new Set(["docker-desktop", "docker-desktop-data"])
+
+// Read LXSS metadata from the Windows registry. This never invokes
+// wsl.exe, so it is safe to call when wsl.exe itself is wedged.
+// DefaultUid === 0 on a user-oriented distro means the first-run
+// "Create a default UNIX user account" step never completed.
+//
+// Uses a `reg query` fallback strategy because some hosts (e.g. Electron
+// spawning PowerShell with certain user profiles) return nothing from the
+// PowerShell registry provider; parsing `reg query` output is ugly but
+// native Windows and always available.
+export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise<WslRegistryDistro[]> {
+ // `reg query` prints each subkey's values in a stable format:
+ //
+ // HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{guid}
+ // DistributionName REG_SZ Ubuntu-24.04
+ // DefaultUid REG_DWORD 0x0
+ // State REG_DWORD 0x1
+ // Version REG_DWORD 0x2
+ // ...
+ const result = await runCommand(
+ "reg.exe",
+ ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss", "/s"],
+ opts,
+ )
+ const stdout = result.stdout
+ if (result.code !== 0 || !stdout) {
+ ;(opts?.onLine ?? (() => undefined))({
+ stream: "stderr",
+ text: `reg query failed code=${result.code} stderr=${result.stderr.slice(0, 200)}`,
+ })
+ return []
+ }
+ const blocks = stdout.split(/\r?\n\r?\n/)
+ const out: WslRegistryDistro[] = []
+ for (const block of blocks) {
+ const header = block.match(/^(HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\\{[^}]+\})/i)
+ if (!header) continue
+ const name = block.match(/^\s+DistributionName\s+REG_SZ\s+(.+?)\s*$/m)?.[1]
+ if (!name) continue
+ const uidHex = block.match(/^\s+DefaultUid\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0"
+ const stateHex = block.match(/^\s+State\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0"
+ const versionHex = block.match(/^\s+Version\s+REG_DWORD\s+0x([0-9a-f]+)\s*$/im)?.[1] ?? "0"
+ out.push({
+ name,
+ defaultUid: Number.parseInt(uidHex, 16),
+ state: Number.parseInt(stateHex, 16),
+ version: Number.parseInt(versionHex, 16),
+ })
+ }
+ return out
+}
+
+export type WslFirstRunCheck =
+ | { status: "ok" }
+ | { status: "needs-first-run"; defaultUid: number }
+ | { status: "not-installed" }
+
+export async function checkWslDistroFirstRun(distro: string, opts?: RunWslOptions): Promise<WslFirstRunCheck> {
+ const distros = await readWslDistrosFromRegistry(opts)
+ const entry = distros.find((d) => d.name === distro)
+ if (!entry) return { status: "not-installed" }
+ if (ALWAYS_ROOT_DISTROS.has(entry.name)) return { status: "ok" }
+ if (entry.defaultUid === 0) return { status: "needs-first-run", defaultUid: entry.defaultUid }
+ return { status: "ok" }
+}
+
+export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) {
+ return runWslInDistro(["sh", "-lc", script], distro, opts)
+}
+
+export function runWslBash(script: string, distro?: string | null, opts?: RunWslOptions) {
+ return runWslInDistro(["bash", "-lc", script], distro, opts)
+}
+
+export async function probeWslRuntime(opts?: RunWslOptions): Promise<WslRuntimeCheck> {
+ const version = await runWsl(["--version"], opts).catch((error) => ({
+ code: 1,
+ signal: null,
+ stdout: "",
+ stderr: error instanceof Error ? error.message : String(error),
+ }))
+
+ if (version.code !== 0) {
+ return {
+ available: false,
+ version: null,
+ status: null,
+ error: summarize(version.stderr || version.stdout) || "WSL is unavailable",
+ }
+ }
+
+ const status = await runWsl(["--status"], opts).catch(() => undefined)
+ return {
+ available: true,
+ version: firstLine(version.stdout),
+ status: status?.code === 0 ? summarize(status.stdout) : null,
+ error: null,
+ }
+}
+
+export async function listInstalledWslDistros(opts?: RunWslOptions) {
+ const result = await runWsl(["--list", "--verbose"], opts)
+ if (result.code !== 0) {
+ throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros")
+ }
+ return parseInstalledDistros(result.stdout)
+}
+
+export async function listOnlineWslDistros(opts?: RunWslOptions) {
+ const result = await runWsl(["--list", "--online"], opts)
+ if (result.code !== 0) {
+ throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros")
+ }
+ return parseOnlineDistros(result.stdout)
+}
+
+export async function installWslRuntime(opts?: RunWslOptions) {
+ return runWsl(["--install", "--no-distribution"], opts)
+}
+
+export async function installWslRuntimeElevated(opts?: RunWslOptions) {
+ const script = [
+ "$ErrorActionPreference = 'Stop'",
+ "$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
+ "if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
+ ].join("; ")
+ return runPowerShell(script, opts)
+}
+
+export async function installWslDistro(name: string, opts?: RunWslOptions) {
+ return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts)
+}
+
+export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
+ return runWslBash(
+ `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`,
+ distro,
+ opts,
+ )
+}
+
+export function wslNeedsRestart(result: WslCommandResult) {
+ return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
+}
+
+export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
+ const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
+ code: 1,
+ signal: null,
+ stdout: "",
+ stderr: error instanceof Error ? error.message : String(error),
+ }))
+ if (executable.code !== 0) {
+ return {
+ name,
+ canExecute: false,
+ hasBash: false,
+ hasCurl: false,
+ username: null,
+ isRoot: null,
+ error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro",
+ }
+ }
+
+ const [bash, curl, user] = await Promise.all([
+ runWslSh("command -v bash >/dev/null && printf yes || printf no", name, opts),
+ runWslSh("command -v curl >/dev/null && printf yes || printf no", name, opts),
+ runWslSh("id -un 2>/dev/null || true", name, opts),
+ ])
+
+ const username = summarize(user.stdout)
+ return {
+ name,
+ canExecute: true,
+ hasBash: bash.code === 0 && summarize(bash.stdout) === "yes",
+ hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes",
+ username: username || null,
+ isRoot: username ? username === "root" : null,
+ error: null,
+ }
+}
+
+async function readWslDefaultUser(distro: string, opts?: RunWslOptions) {
+ const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro)
+ if (!entry || entry.defaultUid === 0) return null
+
+ const passwd = firstLine(
+ (
+ await runWslSh(
+ [
+ "if command -v getent >/dev/null 2>&1; then",
+ ` getent passwd ${entry.defaultUid}`,
+ "else",
+ ` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`,
+ "fi",
+ ].join("\n"),
+ distro,
+ opts,
+ )
+ ).stdout,
+ )
+ if (!passwd) return null
+
+ const parts = passwd.split(":")
+ const username = parts[0]?.trim() ?? ""
+ const home = parts[5]?.trim() ?? ""
+ if (!home) return null
+ return { username: username || null, home }
+}
+
+export async function resolveWslHome(distro: string, opts?: RunWslOptions) {
+ return (await readWslDefaultUser(distro, opts))?.home ?? "/root"
+}
+
+function opencodeCandidate(path: string) {
+ return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi`
+}
+
+export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
+ const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout)
+ if (command && !command.startsWith("/mnt/")) return command
+
+ const home = await resolveWslHome(distro, opts)
+ for (const candidate of [
+ ...(home !== "/root"
+ ? [
+ opencodeCandidate(`${home}/.local/bin/opencode`),
+ opencodeCandidate(`${home}/bin/opencode`),
+ opencodeCandidate(`${home}/.opencode/bin/opencode`),
+ ]
+ : []),
+ 'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
+ 'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
+ 'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
+ 'if [ -x "/usr/local/bin/opencode" ]; then printf "%s\\n" "/usr/local/bin/opencode"; fi',
+ ]) {
+ const resolved = firstLine((await runWslSh(candidate, distro, opts)).stdout)
+ if (resolved) return resolved
+ }
+
+ return null
+}
+
+export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
+ const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
+ return firstLine(result.stdout)
+}
+
+export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
+ return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts)
+}
+
+export function openWslTerminal(distro?: string | null) {
+ return new Promise<void>((resolve, reject) => {
+ const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
+ detached: true,
+ stdio: "ignore",
+ windowsHide: true,
+ })
+ child.once("error", reject)
+ child.once("spawn", () => {
+ child.unref()
+ resolve()
+ })
+ })
+}
+
+function parseInstalledDistros(output: string) {
+ return output.split(/\r?\n/g).flatMap((line) => {
+ const trimmed = line.trim()
+ if (!trimmed) return []
+ const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}(\S+)\s+(\d+)\s*$/)
+ if (!match) return []
+ const [, marker, name, state, version] = match
+ if (!name || /^name$/i.test(name)) return []
+ return [
+ {
+ name: name.trim(),
+ state: state || null,
+ version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10),
+ isDefault: marker === "*",
+ } satisfies WslInstalledDistro,
+ ]
+ })
+}
+
+function parseOnlineDistros(output: string) {
+ return output.split(/\r?\n/g).flatMap((line) => {
+ const trimmed = line.trim()
+ if (!trimmed) return []
+ const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/)
+ if (!match) return []
+ const [, name, label] = match
+ if (/^name$/i.test(name)) return []
+ return [{ name, label: label.trim() } satisfies WslOnlineDistro]
+ })
+}
+
+function firstLine(value: string) {
+ return (
+ value
+ .split(/\r?\n/g)
+ .map((line) => line.trim())
+ .find(Boolean) ?? null
+ )
+}
+
+function summarize(value: string) {
+ return value
+ .split(/\r?\n/g)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .join("\n")
+}
+
+function shellEscape(value: string) {
+ return `'${value.replace(/'/g, `'"'"'`)}'`
+}
diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts
index 296fcb2f1c..faf0d692cb 100644
--- a/packages/desktop-electron/src/preload/index.ts
+++ b/packages/desktop-electron/src/preload/index.ts
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"
-import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
+import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
const api: ElectronAPI = {
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
@@ -11,15 +11,35 @@ const api: ElectronAPI = {
ipcRenderer.removeListener("init-step", handler)
})
},
+ wslServers: {
+ getState: () => ipcRenderer.invoke("wsl-servers-get-state"),
+ subscribe: (cb) => {
+ const handler = (_: unknown, event: WslServersEvent) => cb(event)
+ ipcRenderer.on("wsl-servers-event", handler)
+ return () => ipcRenderer.removeListener("wsl-servers-event", handler)
+ },
+ probeRuntime: () => ipcRenderer.invoke("wsl-servers-probe-runtime"),
+ refreshDistros: () => ipcRenderer.invoke("wsl-servers-refresh-distros"),
+ installWsl: () => ipcRenderer.invoke("wsl-servers-install-wsl"),
+ installDistro: (name) => ipcRenderer.invoke("wsl-servers-install-distro", name),
+ probeDistro: (name) => ipcRenderer.invoke("wsl-servers-probe-distro", name),
+ probeOpencode: (name) => ipcRenderer.invoke("wsl-servers-probe-opencode", name),
+ installOpencode: (name) => ipcRenderer.invoke("wsl-servers-install-opencode", name),
+ openTerminal: (name) => ipcRenderer.invoke("wsl-servers-open-terminal", name),
+ addServer: (distro) => ipcRenderer.invoke("wsl-servers-add", distro),
+ removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id),
+ startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id),
+ stopServer: (id) => ipcRenderer.invoke("wsl-servers-stop", id),
+ cancelJob: () => ipcRenderer.invoke("wsl-servers-cancel"),
+ updateAcknowledgements: (id, acks) => ipcRenderer.invoke("wsl-servers-update-acknowledgements", id, acks),
+ },
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
- getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
- setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
- wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
+ wslPath: (path, mode, distro) => ipcRenderer.invoke("wsl-path", path, mode, distro),
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts
index f8e6d52c7d..18183868ad 100644
--- a/packages/desktop-electron/src/preload/types.ts
+++ b/packages/desktop-electron/src/preload/types.ts
@@ -4,11 +4,120 @@ export type ServerReadyData = {
url: string
username: string | null
password: string | null
+ local: {
+ key: string
+ url: string
+ username: string | null
+ password: string | null
+ }
}
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
-export type WslConfig = { enabled: boolean }
+export type WslServerStep = "wsl" | "distro" | "opencode"
+
+export type WslRuntimeCheck = {
+ available: boolean
+ version: string | null
+ status: string | null
+ error: string | null
+}
+export type WslInstalledDistro = {
+ name: string
+ state: string | null
+ version: number | null
+ isDefault: boolean
+}
+export type WslOnlineDistro = {
+ name: string
+ label: string
+}
+export type WslDistroProbe = {
+ name: string
+ canExecute: boolean
+ hasBash: boolean
+ hasCurl: boolean
+ username: string | null
+ isRoot: boolean | null
+ error: string | null
+}
+export type WslOpencodeCheck = {
+ distro: string
+ resolvedPath: string | null
+ version: string | null
+ expectedVersion: string | null
+ matchesDesktop: boolean | null
+ error: string | null
+}
+export type WslTranscriptLine = {
+ stream: "stdout" | "stderr" | "system"
+ text: string
+ at: number
+}
+
+export type WslServerAcknowledgements = {
+ root: boolean
+ mismatch: { path: string; version: string } | null
+}
+
+export type WslServerConfig = {
+ id: string
+ distro: string
+ acknowledgements: WslServerAcknowledgements
+}
+
+export type WslServerRuntime =
+ | { kind: "starting" }
+ | { kind: "ready"; url: string; username: string | null; password: string | null }
+ | { kind: "failed"; message: string }
+ | { kind: "stopped" }
+
+export type WslServerItem = {
+ config: WslServerConfig
+ runtime: WslServerRuntime
+}
+
+export type WslJob =
+ | { kind: "runtime"; startedAt: number }
+ | { 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: "install-opencode"; distro: string; startedAt: number }
+
+export type WslServersState = {
+ runtime: WslRuntimeCheck | null
+ installed: WslInstalledDistro[]
+ online: WslOnlineDistro[]
+ distroProbes: Record<string, WslDistroProbe>
+ opencodeChecks: Record<string, WslOpencodeCheck>
+ pendingRestart: boolean
+ servers: WslServerItem[]
+ job: WslJob | null
+ transcript: WslTranscriptLine[]
+ lastError: string | null
+}
+export type WslServersEvent = { type: "state"; state: WslServersState }
+
+export type WslServersAPI = {
+ getState: () => Promise<WslServersState>
+ subscribe: (cb: (event: WslServersEvent) => void) => () => void
+ probeRuntime: () => Promise<void>
+ refreshDistros: () => Promise<void>
+ installWsl: () => Promise<void>
+ installDistro: (name: string) => Promise<void>
+ probeDistro: (name: string) => Promise<void>
+ probeOpencode: (name: string) => Promise<void>
+ installOpencode: (name: string) => Promise<void>
+ openTerminal: (name: string) => Promise<void>
+ addServer: (distro: string) => Promise<WslServerConfig>
+ removeServer: (id: string) => Promise<void>
+ startServer: (id: string) => Promise<void>
+ stopServer: (id: string) => Promise<void>
+ cancelJob: () => Promise<void>
+ updateAcknowledgements: (id: string, acks: Partial<WslServerAcknowledgements>) => Promise<void>
+}
export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = {
@@ -19,15 +128,14 @@ export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
+ wslServers: WslServersAPI
getDefaultServerUrl: () => Promise<string | null>
setDefaultServerUrl: (url: string | null) => Promise<void>
- getWslConfig: () => Promise<WslConfig>
- setWslConfig: (config: WslConfig) => Promise<void>
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
parseMarkdownCommand: (markdown: string) => Promise<string>
checkAppExists: (appName: string) => Promise<boolean>
- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
+ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise<string>
resolveAppPath: (appName: string) => Promise<string | null>
storeGet: (name: string, key: string) => Promise<string | null>
storeSet: (name: string, key: string, value: string) => Promise<void>
diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts
index d1590ff048..3dbd50f61a 100644
--- a/packages/desktop-electron/src/renderer/env.d.ts
+++ b/packages/desktop-electron/src/renderer/env.d.ts
@@ -5,8 +5,8 @@ declare global {
api: ElectronAPI
__OPENCODE__?: {
updaterEnabled?: boolean
- wsl?: boolean
deepLinks?: string[]
+ activeServer?: string
}
}
}
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 44f2e6360c..7aae903485 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -1,5 +1,57 @@
// @refresh reload
+// V8's default Error.stackTraceLimit truncates at 10 frames; raise it so
+// reported errors come with a useful frame budget.
+Error.stackTraceLimit = 200
+
+// Install global error listeners before any other module runs so that
+// uncaught errors and rejected promises reach the main process with their
+// full stacks intact. Electron's `console-message` event only forwards the
+// rethrow site, so without these we lose the originating frame.
+window.addEventListener("error", (event) => {
+ const err = event.error
+ const stack = err instanceof Error ? err.stack : null
+ console.error(
+ "[renderer uncaught]",
+ stack ?? event.message,
+ stack ? "" : `${event.filename}:${event.lineno}:${event.colno}`,
+ )
+})
+
+window.addEventListener("unhandledrejection", (event) => {
+ const reason = event.reason
+ // Log as much as possible: stack for Errors, JSON for plain objects with
+ // a fallback to a tagged shape so we never end up with just
+ // "[object Object]" in main.log.
+ if (reason instanceof Error) {
+ console.error("[renderer unhandled rejection]", reason.stack ?? reason.message ?? String(reason))
+ return
+ }
+ let serialized: string
+ try {
+ serialized = JSON.stringify(
+ reason,
+ (_key, value) => {
+ if (value instanceof Error) {
+ return { __error: true, name: value.name, message: value.message, stack: value.stack }
+ }
+ return value
+ },
+ 2,
+ )
+ } catch {
+ serialized = String(reason)
+ }
+ console.error(
+ "[renderer unhandled rejection]",
+ `type=${typeof reason}`,
+ `ctor=${reason?.constructor?.name ?? "null"}`,
+ `keys=${reason && typeof reason === "object" ? Object.keys(reason).join(",") : "n/a"}`,
+ "value:",
+ serialized,
+ )
+})
+
import {
ACCEPTED_FILE_EXTENSIONS,
ACCEPTED_FILE_TYPES,
@@ -13,16 +65,20 @@ import {
PlatformProvider,
ServerConnection,
useCommand,
+ type WslServersEvent,
+ type WslServersState,
} from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import { MemoryRouter } from "@solidjs/router"
-import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { render } from "solid-js/web"
import pkg from "../../package.json"
import { initI18n, t } from "./i18n"
import { UPDATER_ENABLED } from "./updater"
-import { webviewZoom } from "./webview-zoom"
+import { webviewZoom, zoomIn, zoomOut, zoomReset } from "./webview-zoom"
import "./styles.css"
+import { Button } from "@opencode-ai/ui/button"
+import { Splash } from "@opencode-ai/ui/logo"
import { useTheme } from "@opencode-ai/ui/theme"
const root = document.getElementById("root")
@@ -48,6 +104,21 @@ const listenForDeepLinks = () => {
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
}
+function LocalServerStartupError(props: { message: string }) {
+ return (
+ <div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
+ <div class="flex flex-col items-center max-w-md text-center">
+ <Splash class="w-12 h-15 mb-4" />
+ <p class="text-16-medium text-text-strong">Local Server failed to start</p>
+ <p class="mt-2 text-12-regular text-text-weak whitespace-pre-wrap break-words">{props.message}</p>
+ <Button variant="secondary" size="large" class="mt-4" onClick={() => window.api.relaunch()}>
+ Relaunch
+ </Button>
+ </div>
+ </div>
+ )
+}
+
const createPlatform = (): Platform => {
const os = (() => {
const ua = navigator.userAgent
@@ -57,17 +128,25 @@ const createPlatform = (): Platform => {
return undefined
})()
+ const activeWslDistro = () => {
+ const key = window.__OPENCODE__?.activeServer
+ if (!key || !key.startsWith("wsl:")) return undefined
+ return key.slice("wsl:".length)
+ }
+
const wslHome = async () => {
- if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
- return window.api.wslPath("~", "windows").catch(() => undefined)
+ const distro = activeWslDistro()
+ if (!distro) return undefined
+ return window.api.wslPath("~", "windows", distro).catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
- if (!result || !window.__OPENCODE__?.wsl) return result
+ const distro = activeWslDistro()
+ if (!result || !distro) return result
if (Array.isArray(result)) {
- return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
+ return Promise.all(result.map((path) => window.api.wslPath(path, "linux", distro).catch(() => path))) as any
}
- return window.api.wslPath(result, "linux").catch(() => result) as any
+ return window.api.wslPath(result, "linux", distro).catch(() => result) as any
}
const storage = (() => {
@@ -97,6 +176,8 @@ const createPlatform = (): Platform => {
}
})()
+ const wslServersApi = os === "windows" ? window.api.wslServers : undefined
+
return {
platform: "desktop",
os,
@@ -137,8 +218,9 @@ const createPlatform = (): Platform => {
if (os === "windows") {
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
const resolvedPath = await (async () => {
- if (window.__OPENCODE__?.wsl) {
- const converted = await window.api.wslPath(path, "windows").catch(() => null)
+ const distro = activeWslDistro()
+ if (distro) {
+ const converted = await window.api.wslPath(path, "windows", distro).catch(() => null)
if (converted) return converted
}
return path
@@ -194,16 +276,6 @@ const createPlatform = (): Platform => {
return fetch(input, init)
},
- getWslEnabled: async () => {
- const next = await window.api.getWslConfig().catch(() => null)
- if (next) return next.enabled
- return window.__OPENCODE__!.wsl ?? false
- },
-
- setWslEnabled: async (enabled) => {
- await window.api.setWslConfig({ enabled })
- },
-
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
if (!url) return null
@@ -214,6 +286,8 @@ const createPlatform = (): Platform => {
await window.api.setDefaultServerUrl(url)
},
+ wslServers: wslServersApi,
+
getDisplayBackend: async () => {
return window.api.getDisplayBackend().catch(() => null)
},
@@ -243,6 +317,9 @@ const createPlatform = (): Platform => {
let menuTrigger = null as null | ((id: string) => void)
window.api.onMenuCommand((id) => {
+ if (id === "zoom.in") return zoomIn()
+ if (id === "zoom.out") return zoomOut()
+ if (id === "zoom.reset") return zoomReset()
menuTrigger?.(id)
})
listenForDeepLinks()
@@ -263,8 +340,19 @@ render(() => {
const [windowCount] = createResource(() => window.api.getWindowCount())
- // Fetch sidecar credentials (available immediately, before health check)
- const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
+ const [startup] = createResource(async () => {
+ try {
+ return {
+ error: null,
+ sidecar: await window.api.awaitInitialization(() => undefined),
+ }
+ } catch (error) {
+ return {
+ error: error instanceof Error ? error.message : String(error),
+ sidecar: null,
+ }
+ }
+ })
const [defaultServer] = createResource(() =>
platform.getDefaultServer?.().then((url) => {
@@ -273,20 +361,52 @@ render(() => {
)
const [locale] = createResource(loadLocale)
+ const [wslServers, setWslServers] = createSignal<WslServersState | null>(null)
+ if (platform.wslServers) {
+ void platform.wslServers.getState().then((state) => setWslServers(state))
+ const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state))
+ onCleanup(off)
+ }
+
const servers = () => {
- const data = sidecar()
- if (!data) return []
- const server: ServerConnection.Sidecar = {
- displayName: "Local Server",
- type: "sidecar",
- variant: "base",
- http: {
- url: data.url,
- username: data.username ?? undefined,
- password: data.password ?? undefined,
- },
+ const data = startup.latest?.sidecar
+ const list: ServerConnection.Any[] = []
+ if (data) {
+ list.push({
+ displayName: "Local Server",
+ type: "sidecar",
+ variant: "base",
+ http: {
+ url: data.local.url,
+ username: data.local.username ?? undefined,
+ password: data.local.password ?? undefined,
+ },
+ })
+ }
+ const wsl = wslServers()
+ if (wsl) {
+ for (const item of wsl.servers) {
+ const runtime = item.runtime
+ const http =
+ runtime.kind === "ready"
+ ? {
+ url: runtime.url,
+ username: runtime.username ?? undefined,
+ password: runtime.password ?? undefined,
+ }
+ : {
+ url: `http://wsl-${item.config.distro}.invalid`,
+ }
+ list.push({
+ displayName: `WSL: ${item.config.distro}`,
+ type: "sidecar",
+ variant: "wsl",
+ distro: item.config.distro,
+ http,
+ })
+ }
}
- return [server] as ServerConnection.Any[]
+ return list
}
function handleClick(e: MouseEvent) {
@@ -325,11 +445,17 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
- <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
+ <Show when={!defaultServer.loading && !startup.loading && !windowCount.loading && !locale.loading}>
{(_) => {
+ if (startup.latest?.error) {
+ return <LocalServerStartupError message={startup.latest.error} />
+ }
return (
<AppInterface
- defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
+ defaultServer={
+ defaultServer.latest ??
+ ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")
+ }
servers={servers()}
router={MemoryRouter}
>
diff --git a/packages/desktop-electron/src/renderer/webview-zoom.ts b/packages/desktop-electron/src/renderer/webview-zoom.ts
index 9c0a3a3a35..6ff35e2459 100644
--- a/packages/desktop-electron/src/renderer/webview-zoom.ts
+++ b/packages/desktop-electron/src/renderer/webview-zoom.ts
@@ -11,28 +11,73 @@ const OS_NAME = (() => {
return "unknown"
})()
-const [webviewZoom, setWebviewZoom] = createSignal(1)
+const MIN_ZOOM = 0.2
+const MAX_ZOOM = 10
+const KEY_STEP = 0.2
+const WHEEL_STEP = 0.1
-const MAX_ZOOM_LEVEL = 10
-const MIN_ZOOM_LEVEL = 0.2
+const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM)
-const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
+const [webviewZoom, setWebviewZoom] = createSignal(1)
-const applyZoom = (next: number) => {
- setWebviewZoom(next)
- void window.api.setZoomFactor(next)
+const apply = (next: number) => {
+ const clamped = clamp(next)
+ if (Math.abs(clamped - webviewZoom()) < 1e-6) return
+ setWebviewZoom(clamped)
+ void window.api.setZoomFactor(clamped).catch(() => undefined)
}
-window.addEventListener("keydown", (event) => {
- if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
+export const zoomIn = () => apply(webviewZoom() + KEY_STEP)
+export const zoomOut = () => apply(webviewZoom() - KEY_STEP)
+export const zoomReset = () => apply(1)
- let newZoom = webviewZoom()
+// Seed the signal from the main process so renderer and webContents agree
+// across cold starts, reloads, and HMR refreshes (which would otherwise
+// reinitialize the signal to 1 while webContents kept its prior factor).
+void window.api
+ .getZoomFactor()
+ .then((initial) => {
+ if (typeof initial === "number" && Number.isFinite(initial)) {
+ setWebviewZoom(clamp(initial))
+ }
+ })
+ .catch(() => undefined)
- if (event.key === "-") newZoom -= 0.2
- if (event.key === "=" || event.key === "+") newZoom += 0.2
- if (event.key === "0") newZoom = 1
+// Keyboard accelerators. preventDefault stops Chromium's built-in zoom
+// accelerators from firing in parallel (which previously caused races).
+window.addEventListener("keydown", (event) => {
+ const mod = OS_NAME === "macos" ? event.metaKey : event.ctrlKey
+ if (!mod || event.altKey) return
- applyZoom(clamp(newZoom))
+ if (event.key === "-" || event.key === "_") {
+ event.preventDefault()
+ zoomOut()
+ return
+ }
+ if (event.key === "=" || event.key === "+") {
+ event.preventDefault()
+ zoomIn()
+ return
+ }
+ if (event.key === "0") {
+ event.preventDefault()
+ zoomReset()
+ return
+ }
})
+// Wheel zoom. Chromium synthesizes `wheel` with `ctrlKey: true` for trackpad
+// pinch on every platform, so checking ctrlKey uniformly covers pinch-to-zoom
+// as well as real ctrl+scroll / cmd+scroll.
+window.addEventListener(
+ "wheel",
+ (event) => {
+ if (!event.ctrlKey && !event.metaKey) return
+ event.preventDefault()
+ const step = event.deltaY > 0 ? -WHEEL_STEP : WHEEL_STEP
+ apply(webviewZoom() + step)
+ },
+ { passive: false },
+)
+
export { webviewZoom }
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index d6a0ad74f8..875c0bcd60 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -485,7 +485,7 @@ render(() => {
{(_) => {
return (
<AppInterface
- defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
+ defaultServer={defaultServer.latest ?? ServerConnection.Key.make("local:windows")}
servers={servers()}
>
<Inner />
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css
index 1e74763ae2..db6c750f9b 100644
--- a/packages/ui/src/components/dialog.css
+++ b/packages/ui/src/components/dialog.css
@@ -35,7 +35,7 @@
width: 100%;
max-height: 100%;
min-height: 280px;
- overflow: auto;
+ overflow: hidden;
pointer-events: auto;
/* Hide scrollbar */
@@ -102,7 +102,8 @@
display: flex;
flex-direction: column;
flex: 1;
- overflow: hidden;
+ min-height: 0;
+ overflow-y: auto;
&:focus-visible {
outline: none;
diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx
index 981e3f45d7..39003f3b68 100644
--- a/packages/ui/src/components/dialog.tsx
+++ b/packages/ui/src/components/dialog.tsx
@@ -1,6 +1,7 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
-import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
+import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js"
import { useI18n } from "../context/i18n"
+import { DialogContext } from "../context/dialog"
import { IconButton } from "./icon-button"
export interface DialogProps extends ParentProps {
@@ -12,10 +13,19 @@ export interface DialogProps extends ParentProps {
classList?: ComponentProps<"div">["classList"]
fit?: boolean
transition?: boolean
+ // When `false`, clicking the overlay or outside the dialog will not dismiss it.
+ // Default is `true`.
+ dismissOutside?: boolean
}
export function Dialog(props: DialogProps) {
const i18n = useI18n()
+ const dialogCtx = useContext(DialogContext)
+ createEffect(() => {
+ if (!dialogCtx) return
+ if (props.dismissOutside === undefined) return
+ dialogCtx.active?.setDismissOutside(props.dismissOutside)
+ })
return (
<div
data-component="dialog"
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index c1c56212b5..a39f5a0f3f 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -23,10 +23,14 @@ type Active = {
owner: Owner
onClose?: () => void
setClosing: (closing: boolean) => void
+ dismissOutside: () => boolean
+ setDismissOutside: (value: boolean) => void
}
const Context = createContext<ReturnType<typeof init>>()
+export const DialogContext = Context
+
function init() {
const [active, setActive] = createSignal<Active | undefined>()
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -89,12 +93,17 @@ function init() {
const id = Math.random().toString(36).slice(2)
let dispose: (() => void) | undefined
let setClosing: ((closing: boolean) => void) | undefined
+ let setDismissOutsideSignal: ((value: boolean) => void) | undefined
+ let dismissOutsideAccessor: (() => boolean) | undefined
const node = runWithOwner(owner, () =>
createRoot((d: () => void) => {
dispose = d
const [closing, setClosingSignal] = createSignal(false)
setClosing = setClosingSignal
+ const [dismissOutside, setDismissOutside] = createSignal(true)
+ dismissOutsideAccessor = dismissOutside
+ setDismissOutsideSignal = setDismissOutside
return (
<Kobalte
modal
@@ -105,7 +114,12 @@ function init() {
}}
>
<Kobalte.Portal>
- <Kobalte.Overlay data-component="dialog-overlay" onClick={close} />
+ <Kobalte.Overlay
+ data-component="dialog-overlay"
+ onClick={() => {
+ if (dismissOutside()) close()
+ }}
+ />
{element()}
</Kobalte.Portal>
</Kobalte>
@@ -113,9 +127,18 @@ function init() {
}),
)
- if (!dispose || !setClosing) return
-
- setActive({ id, node, dispose, owner, onClose, setClosing })
+ if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return
+
+ setActive({
+ id,
+ node,
+ dispose,
+ owner,
+ onClose,
+ setClosing,
+ dismissOutside: dismissOutsideAccessor,
+ setDismissOutside: setDismissOutsideSignal,
+ })
}
return {
@@ -159,5 +182,8 @@ export function useDialog() {
close() {
ctx.close()
},
+ setDismissOutside(value: boolean) {
+ ctx.active?.setDismissOutside(value)
+ },
}
}