diff --git a/diff.txt b/diff.txt new file mode 100644 index 000000000..351288c8f --- /dev/null +++ b/diff.txt @@ -0,0 +1,4594 @@ +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 +@@ -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]} +

+

{language.t("app.server.retrying")}

++ ++ ++ + + 0}> +
+@@ -285,6 +320,12 @@ export function AppInterface(props: { + router?: Component + 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 ( + + ++ ++
++ ++
++
+ + + +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 ( +
+ +- {isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")} ++ ++ {isAddWslMode() ++ ? "Add WSL server" ++ : isAddMode() ++ ? language.t("dialog.server.add.title") ++ : language.t("dialog.server.edit.title")} ++ +
+ ) + }) +@@ -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 ( +- ++ +
+ ++ ++ } ++ > ++ ++ + } + > + 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 ( +
+
+@@ -562,12 +620,12 @@ export function DialogSelectServer() { + } + showCredentials + /> +-
++
+ + + + +- ++ + + + + +- { +- if (i.type !== "http") return +- startEdit(i) +- }} +- > +- {language.t("dialog.server.menu.edit")} +- +- ++ ++ { ++ if (i.type !== "http") return ++ startEdit(i) ++ }} ++ > ++ {language.t("dialog.server.menu.edit")} ++ ++ ++ ++ void handleRetryWsl(i)}> ++ Retry start ++ ++ ++ + setDefault(key)}> + + {language.t("dialog.server.menu.default")} + + + +- ++ + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + +- +- handleRemove(ServerConnection.key(i))} +- class="text-text-on-critical-base hover:bg-surface-critical-weak" +- > +- {language.t("dialog.server.menu.delete")} +- ++ ++ ++ (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} ++ class="text-text-on-critical-base hover:bg-surface-critical-weak" ++ > ++ ++ {language.t("dialog.server.menu.delete")} ++ ++ ++ + + + +@@ -621,17 +690,32 @@ export function DialogSelectServer() { + +
+ +- {language.t("dialog.server.add.button")} +- ++ ++
++ ++ ++ ++ ++
++
+ } + > + ++ )} ++ ++
++ ++ ++ ++
++
++
WSL
++ ++ ++ ++
++
{wslMessage()}
++ ++
++
Windows restart required.
++ ++
++
++
++
++ ++ ++
++
Choose a distro
++
{distroMessage()}
++ ++
++ 0} ++ fallback={ ++
++ {current()?.installed.length ++ ? "All installed distros are already added." ++ : current()?.runtime?.available ++ ? "No distros detected yet." ++ : "Checking distros..."} ++
++ } ++ > ++ ++ {(item) => ( ++ ++ )} ++ ++
++
++ ++ 0}> ++
++
++
Install
++ ++
++
++ ++ {(item) => { ++ const selected = () => store.installTarget === item.name ++ return ( ++ ++ ) ++ }} ++ ++
++
++
++ ++ ++
++ ++
WSL 2 is required.
++
++ ++ {(message) =>
{message()}
} ++
++ ++
This distro needs bash and curl.
++
++ ++
++ This distro is using the root user right now. ++
++
++
++
++ ++ ++
++
++ ++ ++
++
++
OpenCode
++ ++ ++ ++
++
{opencodeMessage()}
++ ++ {(check) => ( ++
++
Path: {check().resolvedPath ?? "not found"}
++
++ Version: {check().version ?? "unknown"} ++ ++ {(expected) => {` · desktop ${expected()}`}} ++ ++
++
++ Installed version does not match the desktop app version. ++
++
++ )} ++
++
++
++
++ ++ ++ {(progress) => ( ++
++
++ ++
Progress
++
++
{progress().title}
++
++ ++ {(line) => ( ++
++ {line.text} ++
++ )} ++
++
++
++ )} ++
++ ++ 0}> ++
++
Diagnostics
++
++ {(line) =>
{line.text}
}
++
++
++
++ ++
++ ++ ++
++
++
++ ) ++} ++ ++function requestError(language: ReturnType, 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, 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(null) +- const [rightMount, setRightMount] = createSignal(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 }) { + 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) + }} + > + +@@ -329,7 +348,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) { + const run = ++dialogRun + void import("./dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return +- dialog.show(() => , defaultServer.refresh) ++ dialog.show( ++ () => 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 ++ opencodeChecks: Record ++ pendingRestart: boolean ++ servers: WslServerItem[] ++ job: WslJob | null ++ transcript: WslTranscriptLine[] ++ lastError: string | null ++} ++export type WslServersEvent = { type: "state"; state: WslServersState } ++ ++export type WslServersPlatform = { ++ getState(): Promise ++ subscribe(cb: (event: WslServersEvent) => void): () => void ++ probeRuntime(): Promise ++ refreshDistros(): Promise ++ installWsl(): Promise ++ installDistro(name: string): Promise ++ probeDistro(name: string): Promise ++ probeOpencode(name: string): Promise ++ installOpencode(name: string): Promise ++ openTerminal(name: string): Promise ++ addServer(distro: string): Promise ++ removeServer(id: string): Promise ++ startServer(id: string): Promise ++ stopServer(id: string): Promise ++ cancelJob(): Promise ++ updateAcknowledgements(id: string, acks: Partial): Promise ++} ++ + 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 + +- /** Get the configured WSL integration (desktop only) */ +- getWslEnabled?(): Promise +- +- /** Set the configured WSL integration (desktop only) */ +- setWslEnabled?(config: boolean): Promise | void ++ /** Manage WSL sidecar servers (Electron on Windows only) */ ++ wslServers?: WslServersPlatform + + /** Get the preferred display backend (desktop only) */ + getDisplayBackend?(): Promise | 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() + + 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 = 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(() => )} ++ onClick={() => dialog.show(() => navigate("/")} />)} + > +
{ + if (dialogDead || dialogRun !== run) return +- dialog.show(() => ) ++ dialog.show(() => 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]" + > +- ++ + + {(directory) => ( + layout.projects.list() ++ const projectIds = createMemo(() => projects().map((project) => project.worktree)) + const projectOverlay = () => store.activeProject} /> + const sidebarContent = (mobile?: boolean) => ( + layout.sidebar.opened()} + aimMove={aim.move} + projects={projects} +- renderProject={(project) => ( +- +- )} ++ projectIds={projectIds} ++ renderProject={(worktree) => { ++ const project = createMemo(() => projects().find((item) => item.worktree === worktree)) ++ return ( ++ ++ {(project) => ( ++ ++ )} ++ ++ ) ++ }} + 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 + } + ++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 + activeProject: Accessor +@@ -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 + aimMove: (event: MouseEvent) => void + projects: Accessor +- renderProject: (project: LocalProject) => JSX.Element ++ projectIds: Accessor ++ renderProject: (worktree: string) => JSX.Element + handleDragStart: (event: unknown) => void + handleDragEnd: () => void + handleDragOver: (event: DragEvent) => void +@@ -63,8 +65,8 @@ export const SidebarContent = (props: { + + +
+- p.worktree)}> +- {(project) => props.renderProject(project)} ++ ++ {(worktree) => props.renderProject(worktree)} + + 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 + activeWorkspace: Accessor +@@ -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 ++ // {(dir) => } ++ // where `project()` can flip to undefined while the enclosing ++ // 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(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]> = [] ++ 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 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 { + 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() + + const pendingDeepLinks: string[] = [] + + const serverReady = defer() ++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() : 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((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() { + 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 ++ getWslServersState: () => Promise | WslServersState ++ onWslServersEvent: (listener: (event: WslServersEvent) => void) => () => void ++ wslServersProbeRuntime: () => Promise | void ++ wslServersRefreshDistros: () => Promise | void ++ wslServersInstallWsl: () => Promise | void ++ wslServersInstallDistro: (name: string) => Promise | void ++ wslServersProbeDistro: (name: string) => Promise | void ++ wslServersProbeOpencode: (name: string) => Promise | void ++ wslServersInstallOpencode: (name: string) => Promise | void ++ wslServersOpenTerminal: (name: string) => Promise | void ++ wslServersAddServer: (distro: string) => Promise | WslServerConfig ++ wslServersRemoveServer: (id: string) => Promise | void ++ wslServersStartServer: (id: string) => Promise | void ++ wslServersStopServer: (id: string) => Promise | void ++ wslServersCancelJob: () => Promise | void ++ wslServersUpdateAcknowledgements: (id: string, acks: Partial) => Promise | void + getDefaultServerUrl: () => Promise | string | null + setDefaultServerUrl: (url: string | null) => Promise | void +- getWslConfig: () => Promise +- setWslConfig: (config: WslConfig) => Promise | void + getDisplayBackend: () => Promise + setDisplayBackend: (backend: string | null) => Promise | void + parseMarkdown: (markdown: string) => Promise | string + checkAppExists: (appName: string) => Promise | boolean +- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise ++ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise + resolveAppPath: (appName: string) => Promise + loadingWindowComplete: () => void + runUpdater: (alertOnFail: boolean) => Promise | 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) => ++ 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 } + +@@ -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((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 { ++ // 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((_, 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((_, 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 { + 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 ++ ++type ControllerLogger = { ++ log: (message: string, meta?: unknown) => void ++ error: (message: string, meta?: unknown) => void ++} ++ ++export type WslServersController = ReturnType ++ ++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() ++ const startAttempts = new Map() ++ let jobAbort: AbortController | undefined ++ ++ const emit = () => { ++ for (const listener of listeners) listener({ type: "state", state }) ++ } ++ ++ const setState = (next: Partial) => { ++ state = { ...state, ...next } ++ emit() ++ } ++ ++ const appendTranscript = (line: Omit) => { ++ 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 (job: WslJob, runner: (abort: AbortController) => Promise) => { ++ 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 { ++ 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) { ++ 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 ++ 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 ++ 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) : {} ++ const mismatch = ++ record.mismatch && typeof record.mismatch === "object" ? (record.mismatch as Record) : 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((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 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 { ++ // `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 { ++ 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 { ++ 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 { ++ 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((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 ++ opencodeChecks: Record ++ pendingRestart: boolean ++ servers: WslServerItem[] ++ job: WslJob | null ++ transcript: WslTranscriptLine[] ++ lastError: string | null ++} ++export type WslServersEvent = { type: "state"; state: WslServersState } ++ ++export type WslServersAPI = { ++ getState: () => Promise ++ subscribe: (cb: (event: WslServersEvent) => void) => () => void ++ probeRuntime: () => Promise ++ refreshDistros: () => Promise ++ installWsl: () => Promise ++ installDistro: (name: string) => Promise ++ probeDistro: (name: string) => Promise ++ probeOpencode: (name: string) => Promise ++ installOpencode: (name: string) => Promise ++ openTerminal: (name: string) => Promise ++ addServer: (distro: string) => Promise ++ removeServer: (id: string) => Promise ++ startServer: (id: string) => Promise ++ stopServer: (id: string) => Promise ++ cancelJob: () => Promise ++ updateAcknowledgements: (id: string, acks: Partial) => Promise ++} + + export type LinuxDisplayBackend = "wayland" | "auto" + export type TitlebarTheme = { +@@ -19,15 +128,14 @@ export type ElectronAPI = { + killSidecar: () => Promise + installCli: () => Promise + awaitInitialization: (onStep: (step: InitStep) => void) => Promise ++ wslServers: WslServersAPI + getDefaultServerUrl: () => Promise + setDefaultServerUrl: (url: string | null) => Promise +- getWslConfig: () => Promise +- setWslConfig: (config: WslConfig) => Promise + getDisplayBackend: () => Promise + setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise + parseMarkdownCommand: (markdown: string) => Promise + checkAppExists: (appName: string) => Promise +- wslPath: (path: string, mode: "windows" | "linux" | null) => Promise ++ wslPath: (path: string, mode: "windows" | "linux" | null, distro?: string | null) => Promise + resolveAppPath: (appName: string) => Promise + storeGet: (name: string, key: string) => Promise + storeSet: (name: string, key: string, value: string) => Promise +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 ( ++
++
++ ++

Local Server failed to start

++

{props.message}

++ ++
++
++ ) ++} ++ + 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 (result: T | null): Promise => { +- 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(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 ( + + +- ++ + {(_) => { ++ if (startup.latest?.error) { ++ return ++ } + return ( + +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 ( + + +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 ( +
void + setClosing: (closing: boolean) => void ++ dismissOutside: () => boolean ++ setDismissOutside: (value: boolean) => void + } + + const Context = createContext>() + ++export const DialogContext = Context ++ + function init() { + const [active, setActive] = createSignal() + const timer = { current: undefined as ReturnType | 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 ( + + +- ++ { ++ if (dismissOutside()) close() ++ }} ++ /> + {element()} + + +@@ -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) ++ }, + } + } diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 93eaf0df4..9fafcc1cd 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -13,11 +13,36 @@ 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 type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" const DEFAULT_USERNAME = "opencode" +const cachedServerStatus = new Map() + +function versionOlderThan(current: string | null | undefined, expected: string | null | undefined) { + if (!current || !expected) return false + + const parse = (value: string) => { + const match = value.match(/v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/) + if (!match) return + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4] ?? null, + } + } + + const left = parse(current) + const right = parse(expected) + if (!left || !right) return false + if (left.major !== right.major) return left.major < right.major + if (left.minor !== right.minor) return left.minor < right.minor + if (left.patch !== right.patch) return left.patch < right.patch + return !!left.prerelease && !right.prerelease +} interface DialogSelectServerProps { initialView?: "list" | "add-wsl" @@ -186,6 +211,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const checkServerHealth = useCheckServerHealth() const [store, setStore] = createStore({ status: {} as Record, + wslState: undefined as WslServersState | undefined, addServer: { url: "", name: "", @@ -319,6 +345,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) + const healthPollKey = createMemo(() => + items() + .map((conn) => + [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"), + ) + .join("\n\n"), + ) + const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key) const sortedItems = createMemo(() => { const list = items() @@ -333,7 +367,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) + const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b))) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) @@ -346,27 +380,74 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { results[ServerConnection.key(conn)] = await checkServerHealth(conn.http) }), ) + for (const [key, value] of Object.entries(results)) { + cachedServerStatus.set(ServerConnection.Key.make(key), value) + } setStore("status", reconcile(results)) } createEffect(() => { - items() + healthPollKey() void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) + createEffect(() => { + const api = platform.wslServers + if (!api) return + let dead = false + void api + .getState() + .then((state) => { + if (dead) return + setStore("wslState", reconcile(state)) + }) + .catch((err) => { + if (dead) return + showRequestError(language, err) + }) + const off = api.subscribe((event) => { + setStore("wslState", reconcile(event.state)) + }) + onCleanup(() => { + dead = true + off() + }) + }) + + const wslCheck = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return null + return store.wslState?.opencodeChecks[conn.distro] ?? null + } + + const displayVersion = (conn: ServerConnection.Any) => { + if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined + return undefined + } + async function select(conn: ServerConnection.Any, persist?: boolean) { if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return dialog.close() + const nextKey = ServerConnection.key(conn) + const changed = server.key !== nextKey + if (persist && conn.type === "http") { server.add(conn) - props.onNavigateHome?.() + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } return } batch(() => { - props.onNavigateHome?.() - server.setActive(ServerConnection.key(conn)) + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } else { + props.onNavigateHome?.() + } + server.setActive(nextKey) }) } @@ -552,6 +633,18 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } } + async function handleUpdateWsl(conn: ServerConnection.Any) { + if (conn.type !== "sidecar" || conn.variant !== "wsl") return + const api = platform.wslServers + if (!api) return + try { + await api.installOpencode(conn.distro) + await refreshHealth() + } catch (err) { + showRequestError(language, err) + } + } + return (
@@ -601,15 +694,32 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { {(i) => { const key = ServerConnection.key(i) const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" + const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined + const hasMenuActionsBeforeDelete = () => + i.type === "http" || (isWslSidecar && health(key)?.healthy === false) + const outdated = () => { + const check = wslCheck(i) + return versionOlderThan(check?.version, check?.expectedVersion) + } + const opencodeAction = () => { + const check = wslCheck(i) + if (!check) return null + if (!check.resolvedPath) return "Install OpenCode" + if (outdated()) return "Update OpenCode" + return null + } + const updating = () => + store.wslState?.job?.kind === "install-opencode" && store.wslState.job.distro === wslDistro return (
- +
@@ -621,6 +731,23 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { showCredentials />
+ + {(label) => ( + + )} + @@ -647,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { {language.t("dialog.server.menu.edit")} - + void handleRetryWsl(i)}> Retry start @@ -666,8 +793,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { - + + + (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} class="text-text-on-critical-base hover:bg-surface-critical-weak" diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index d971051ee..a396098a2 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -10,6 +10,18 @@ import { usePlatform } from "@/context/platform" const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"] +function isHiddenDistro(name: string) { + return /^docker-desktop(?:-data)?$/i.test(name) +} + +function parseProgressPercent(text: string) { + const match = text.match(/(\d{1,3}(?:[.,]\d+)?)\s*%/) + if (!match) return null + const value = Number.parseFloat(match[1]!.replace(",", ".")) + if (!Number.isFinite(value)) return null + return Math.max(0, Math.min(99, Math.floor(value))) +} + interface DialogWslServerProps { onAdded?: () => void } @@ -66,7 +78,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { if (!distro) return null return (current()?.installed ?? []).find((item) => item.name === distro) ?? null }) - const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null) + const visibleInstalledDistros = createMemo(() => + (current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)), + ) + const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name))) + const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null) const opencodeCheck = createMemo(() => { const distro = selectedDistro() if (!distro) return null @@ -97,17 +113,27 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { }) const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro))) const addableInstalledDistros = createMemo(() => { - return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name)) + return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name)) }) const installableDistros = createMemo(() => { - const online = current()?.online ?? [] - const installed = new Set((current()?.installed ?? []).map((item) => item.name)) + const online = visibleOnlineDistros() + const installed = new Set(visibleInstalledDistros().map((item) => item.name)) const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name)) return online .filter((item) => !installed.has(item.name)) .filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu)) }) const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null) + const installingDistro = createMemo(() => current()?.job?.kind === "install-distro") + const installDistroPercent = createMemo(() => { + if (!installingDistro()) return null + const transcript = current()?.transcript ?? [] + for (let i = transcript.length - 1; i >= 0; i--) { + const percent = parseProgressPercent(transcript[i]!.text) + if (percent !== null) return percent + } + return null + }) const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart) const distroReady = createMemo(() => { const probe = selectedProbe() @@ -284,12 +310,12 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { return (
Loading...
}> -
+
{(item) => ( +
+ + + {installDistroPercent()}% + + + + + + +
serverName(props.conn)) + const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl") + const version = createMemo(() => props.version ?? props.status?.version) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) { createEffect(() => { name() props.conn.http.url - props.status?.version + version() queueMicrotask(check) }) @@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) { const tooltipValue = () => ( {serverName(props.conn, true)} - - v{props.status?.version} + + WSL + + + v{version()} ) @@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) { {name()} + + + WSL + + + - v{props.status?.version} + v{version()} } diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index cad0b0673..1034f0676 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -12,7 +12,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { setServerSwitching } from "@/utils/server-switch" @@ -91,7 +91,7 @@ const useDefaultServerKey = ( get: (() => string | Promise | null | undefined) | undefined, ) => { const [state, setState] = createStore({ - url: undefined as string | undefined, + key: undefined as ServerConnection.Key | undefined, tick: 0, }) @@ -100,7 +100,7 @@ const useDefaultServerKey = ( let dead = false const result = get?.() if (!result) { - setState("url", undefined) + setState("key", undefined) onCleanup(() => { dead = true }) @@ -110,7 +110,7 @@ const useDefaultServerKey = ( if (result instanceof Promise) { void result.then((next) => { if (dead) return - setState("url", next ? normalizeServerUrl(next) : undefined) + setState("key", next ? ServerConnection.Key.make(next) : undefined) }) onCleanup(() => { dead = true @@ -118,18 +118,14 @@ const useDefaultServerKey = ( return } - setState("url", normalizeServerUrl(result)) + setState("key", ServerConnection.Key.make(result)) onCleanup(() => { dead = true }) }) return { - key: () => { - const u = state.url - if (!u) return - return ServerConnection.key({ type: "http", http: { url: u } }) - }, + key: () => state.key, refresh: () => setState("tick", (value) => value + 1), } } @@ -306,7 +302,13 @@ export function StatusPopoverBody(props: { shown: Accessor }) { setTimeout(() => { try { batch(() => { - navigate("/") + if (server.key !== key) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } + } else { + navigate("/") + } server.setActive(key) }) } finally { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index edbbd752c..4997a16bd 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -74,6 +74,11 @@ const errorName = (err: unknown) => { return typeof errorName === "string" ? errorName : undefined } +const logTerminal = (phase: string, input: Record) => { + if (!import.meta.env.DEV) return + console.log(`[terminal ui] ${JSON.stringify({ phase, ...input })}`) +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -169,11 +174,11 @@ export const Terminal = (props: TerminalProps) => { const server = useServer() const directory = sdk.directory const client = sdk.client - const url = sdk.url const auth = server.current?.http const username = auth?.username ?? "opencode" const password = auth?.password ?? "" - const sameOrigin = new URL(url, location.href).origin === location.origin + const currentUrl = () => server.current?.http.url ?? sdk.url + const sameOrigin = () => new URL(currentUrl(), location.href).origin === location.origin let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"]) const id = local.pty.id @@ -450,20 +455,32 @@ export const Terminal = (props: TerminalProps) => { output.flush(resolve) }) - if (restore && restoreSize) { + // Defer the serialised `restore` buffer until the WebSocket actually + // opens against the live PTY. Previously we wrote it synchronously + // before connect, which painted stale content on screen whenever the + // sidecar had restarted (e.g. a server swap): every saved pty id + // belongs to the old sidecar, so connect eventually fails and the + // clone handler wipes the buffer — but you'd see the old bash/pwsh + // scrollback flash first. Now `restore` is only applied once we know + // the pty is real (handleOpen), and if connect fails clone clears + // `buffer` in the store so the next mount has nothing to replay. + fit.fit() + scheduleSize(t.cols, t.rows) + startResize() + + let restored = false + const applyRestore = async () => { + if (restored) return + restored = true + if (!restore) return + logTerminal("restore.apply", { + id, + serverKey: server.key ?? null, + directory, + restoreLength: restore.length, + }) await write(restore) - fit.fit() - scheduleSize(t.cols, t.rows) if (scrollY !== undefined) t.scrollToLine(scrollY) - startResize() - } else { - fit.fit() - scheduleSize(t.cols, t.rows) - if (restore) { - await write(restore) - if (scrollY !== undefined) t.scrollToLine(scrollY) - } - startResize() } const once = { value: false } @@ -509,17 +526,34 @@ export const Terminal = (props: TerminalProps) => { if (disposed) return drop?.() - const next = new URL(url + `/pty/${id}/connect`) + const baseUrl = currentUrl() + if (sdk.url !== baseUrl) { + console.error( + `[terminal panic] sdk.url mismatch id=${id} serverKey=${server.key ?? ""} directory=${directory} sdkUrl=${sdk.url} currentUrl=${baseUrl}`, + ) + } + + const next = new URL(baseUrl + `/pty/${id}/connect`) next.searchParams.set("directory", directory) next.searchParams.set("cursor", String(seek)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" - if (!sameOrigin && password) { + if (!sameOrigin() && password) { next.searchParams.set("auth_token", btoa(`${username}:${password}`)) // For same-origin requests, let the browser reuse the page's existing auth. next.username = username next.password = password } + logTerminal("socket.open", { + id, + serverKey: server.key ?? null, + directory, + restoreLength: restore.length, + sdkUrl: sdk.url, + currentUrl: baseUrl, + wsUrl: next.toString(), + }) + const socket = new WebSocket(next) socket.binaryType = "arraybuffer" ws = socket @@ -527,6 +561,16 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { if (disposed) return tries = 0 + logTerminal("socket.connected", { + id, + serverKey: server.key ?? null, + directory, + currentUrl: baseUrl, + }) + // Paint the saved buffer now that we've confirmed the pty really + // exists on the current sidecar. Fire-and-forget: write()'s own + // flush keeps the data ordered with incoming WS messages. + void applyRestore() local.onConnect?.() scheduleSize(t.cols, t.rows) } @@ -581,6 +625,14 @@ export const Terminal = (props: TerminalProps) => { socket.removeEventListener("close", handleClose) if (disposed) return if (event.code === 1000) return + logTerminal("socket.closed", { + id, + serverKey: server.key ?? null, + directory, + code: event.code, + reason: event.reason || null, + currentUrl: baseUrl, + }) retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } @@ -591,6 +643,29 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("close", handleClose) } + // If we're reconnecting to a saved pty AND we have a serialised buffer + // to replay, verify the pty still exists on the current sidecar BEFORE + // upgrading the WebSocket. Hono's upgradeWebSocket handler throws + // "Session not found" inside `onOpen` (packages/opencode/src/server/ + // routes/instance/pty.ts:196-205), which means the client still gets a + // brief `open` event before the server closes the socket — enough to + // fire handleOpen and paint the stale buffer. Pre-checking turns this + // into a single pty.get() round-trip that routes directly into the + // clone path on NotFound, so restore never runs against a dead pty. + if (restore) { + logTerminal("restore.inspect", { + id, + serverKey: server.key ?? null, + directory, + restoreLength: restore.length, + }) + if (await gone()) { + if (!disposed) fail(new Error("Session not found")) + return + } + if (disposed) return + } + open() } @@ -606,6 +681,13 @@ export const Terminal = (props: TerminalProps) => { }) onCleanup(() => { + logTerminal("cleanup", { + id, + serverKey: server.key ?? null, + directory, + cursor, + restoreLength: restore.length, + }) disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index e53d60d5a..973ecc66b 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -235,7 +235,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }) return { - url: currentServer.http.url, + get url() { + return server.current?.http.url ?? currentServer.http.url + }, client: sdk, event: { on: emitter.on.bind(emitter), diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 096ef23db..f6e94963e 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -171,6 +171,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (state.active !== input) setState("active", input) } + function nextActiveKey(exclude?: ServerConnection.Key) { + const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude) + const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer) + const next = preferred ?? available[0] + return next ? ServerConnection.key(next) : props.defaultServer + } + function add(input: ServerConnection.Http) { const url_ = normalizeServerUrl(input.http.url) if (!url_) return @@ -192,8 +199,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( batch(() => { setStore("list", list) if (state.active === key) { - const next = list[0] - setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer) + setState("active", nextActiveKey(key)) } }) } @@ -239,6 +245,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const current: Accessor = createMemo( () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], ) + + createEffect(() => { + const list = allServers() + if (!list.length) return + if (list.some((conn) => ServerConnection.key(conn) === state.active)) return + setState("active", nextActiveKey(state.active)) + }) + const isLocal = createMemo(() => { const c = current() return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url)) diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index 6e07e0312..e33893ba7 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, mock, test } from "bun:test" -let getWorkspaceTerminalCacheKey: (dir: string) => string +let getWorkspaceTerminalCacheKey: (dir: string, serverKey: string) => string let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let migrateTerminalState: (value: unknown) => unknown @@ -22,8 +22,9 @@ beforeAll(async () => { }) describe("getWorkspaceTerminalCacheKey", () => { - test("uses workspace-only directory cache key", () => { - expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") + test("includes the server in the workspace cache key", () => { + expect(getWorkspaceTerminalCacheKey("/repo", "local:windows")).toBe("/repo:local:windows:__workspace__") + expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("/repo:wsl:Debian:__workspace__") }) }) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 482f55c71..c1f394166 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" +import { useServer } from "./server" import type { Platform } from "./platform" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" @@ -21,6 +22,11 @@ export type LocalPTY = { const WORKSPACE_KEY = "__workspace__" const MAX_TERMINAL_SESSIONS = 20 +const debugTerminal = (phase: string, input: Record) => { + if (!import.meta.env.DEV) return + console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`) +} + function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -82,8 +88,8 @@ export function migrateTerminalState(value: unknown) { } } -export function getWorkspaceTerminalCacheKey(dir: string) { - return `${dir}:${WORKSPACE_KEY}` +export function getWorkspaceTerminalCacheKey(dir: string, serverKey: string) { + return `${dir}:${serverKey}:${WORKSPACE_KEY}` } export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { @@ -111,10 +117,11 @@ const trimTerminal = (pty: LocalPTY) => { } export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) { - const key = getWorkspaceTerminalCacheKey(dir) for (const cache of caches) { - const entry = cache.get(key) - entry?.value.clear() + for (const [key, entry] of cache.entries()) { + if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue + entry.value.clear() + } } void removePersisted(Persist.workspace(dir, "terminal"), platform) @@ -130,14 +137,25 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } -function createWorkspaceTerminalSession(sdk: ReturnType, dir: string, legacySessionID?: string) { +function createWorkspaceTerminalSession( + sdk: ReturnType, + dir: string, + serverKey: string, + legacySessionID?: string, +) { const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID) + const target = { + ...Persist.workspace(dir, `${serverKey}:terminal`, legacy), + migrate: migrateTerminalState, + } + // Scope persisted terminal state by server so switching servers behaves + // like switching projects: a fresh session for the new server+dir pair, + // while the other server's state stays intact until you swap back. PTY + // ids, scrollback, and WebSocket connections are all server-scoped, so + // cross-server persistence was showing stale output on swap. const [store, setStore, _, ready] = persisted( - { - ...Persist.workspace(dir, "terminal", legacy), - migrate: migrateTerminalState, - }, + target, createStore<{ active?: string all: LocalPTY[] @@ -146,6 +164,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }), ) + debugTerminal("session.create", { + dir, + serverKey, + storage: target.storage, + key: target.key, + legacySessionID: legacySessionID ?? null, + }) + const pickNextTerminalNumber = () => { const existingTitleNumbers = new Set( store.all.flatMap((pty) => { @@ -186,6 +212,16 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str onCleanup(unsub) const update = (client: ReturnType["client"], pty: Partial & { id: string }) => { + debugTerminal("session.update", { + dir, + serverKey, + id: pty.id, + title: pty.title ?? null, + hasBuffer: typeof pty.buffer === "string", + bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0, + cursor: pty.cursor ?? null, + scrollY: pty.scrollY ?? null, + }) const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { @@ -202,11 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str const currentIndex = store.all.findIndex((item) => item.id === pty.id) if (currentIndex >= 0) setStore("all", currentIndex, previous) } - console.error("Failed to update terminal", error) + console.error( + `Failed to update terminal ${JSON.stringify({ + ptyID: pty.id, + title: pty.title, + error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error, + })}`, + ) }) } const clone = async (client: ReturnType["client"], id: string) => { + debugTerminal("session.clone.start", { dir, serverKey, id }) const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return @@ -220,6 +263,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }) if (!next?.data) return + debugTerminal("session.clone.done", { + dir, + serverKey, + id, + nextID: next.data.id ?? null, + title: next.data.title ?? pty.title, + }) + const active = store.active === pty.id batch(() => { @@ -252,11 +303,19 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str new() { const nextNumber = pickNextTerminalNumber() + debugTerminal("session.new", { dir, serverKey, nextNumber }) + sdk.client.pty .create({ title: defaultTitle(nextNumber) }) .then((pty: { data?: { id?: string; title?: string } }) => { const id = pty.data?.id if (!id) return + debugTerminal("session.new.done", { + dir, + serverKey, + id, + title: pty.data?.title ?? defaultTitle(nextNumber), + }) const newTerminal = { id, title: pty.data?.title ?? defaultTitle(nextNumber), @@ -289,6 +348,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str }, bind() { const client = sdk.client + debugTerminal("session.bind", { + dir, + serverKey, + active: store.active ?? null, + all: store.all.map((item) => item.id), + }) return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -357,6 +422,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() + const server = useServer() const params = useParams() const cache = new Map() @@ -364,7 +430,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont onCleanup(() => caches.delete(cache)) const disposeAll = () => { - // Snapshot disposers, then defer them to a microtask. When this runs + // Snapshot disposers, then defer them to a macrotask. 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 @@ -372,7 +438,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont // 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())) + if (pending.length) setTimeout(() => pending.forEach((d) => d()), 0) } onCleanup(disposeAll) @@ -387,18 +453,33 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } } - const loadWorkspace = (dir: string, legacySessionID?: string) => { - // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. - const key = getWorkspaceTerminalCacheKey(dir) + const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => { + // Session ids, PTY ids, and terminal buffers are server-scoped. Project + // swaps remount this subtree, but server swaps do not, so the in-memory + // cache must be partitioned by server as well as directory. + const key = getWorkspaceTerminalCacheKey(dir, serverKey) const existing = cache.get(key) if (existing) { + debugTerminal("workspace.cache.hit", { + dir, + serverKey, + key, + legacySessionID: legacySessionID ?? null, + }) cache.delete(key) cache.set(key, existing) return existing.value } + debugTerminal("workspace.cache.miss", { + dir, + serverKey, + key, + legacySessionID: legacySessionID ?? null, + }) + const entry = createRoot((dispose) => ({ - value: createWorkspaceTerminalSession(sdk, dir, legacySessionID), + value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID), dispose, })) @@ -407,16 +488,51 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id)) + const unsupported = createMemo(() => { + const current = server.current + return current?.type === "sidecar" && current.variant === "wsl" && params.dir?.startsWith("/mnt/") + }) + + const unsupportedWorkspace = { + ready: () => true, + all: () => [] as LocalPTY[], + active: () => undefined as string | undefined, + clear() {}, + new() {}, + update(_pty: Partial & { id: string }) {}, + trim(_id: string) {}, + trimAll() {}, + clone: async (_id: string) => {}, + bind() { + return { + trim(_id: string) {}, + update(_pty: Partial & { id: string }) {}, + clone: async (_id: string) => {}, + } + }, + open(_id: string) {}, + close: async (_id: string) => {}, + move(_id: string, _to: number) {}, + next() {}, + previous() {}, + } as unknown as ReturnType + + const workspace = createMemo(() => { + if (unsupported()) return unsupportedWorkspace + const key = server.key + if (!key) return unsupportedWorkspace + return loadWorkspace(params.dir!, key, params.id) + }) createEffect( on( () => ({ dir: params.dir, id: params.id }), (next, prev) => { - if (!prev?.dir) return + const prevKey = server.key + if (!prev?.dir || !prevKey) return if (next.dir === prev.dir && next.id === prev.id) return if (next.dir === prev.dir && next.id) return - loadWorkspace(prev.dir, prev.id).trimAll() + loadWorkspace(prev.dir, prevKey, prev.id).trimAll() }, { defer: true }, ), @@ -431,7 +547,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont trim: (id: string) => workspace().trim(id), trimAll: () => workspace().trimAll(), clone: (id: string) => workspace().clone(id), - bind: () => workspace(), + bind: () => workspace().bind(), open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index a13fd34ef..1b054c4ff 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -65,6 +65,21 @@ function retryable(error: unknown, signal?: AbortSignal) { return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) } +function serializeError(error: unknown): unknown { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + stack: error.stack, + } + } + return error +} + +function stringifyLog(label: string, value: unknown) { + return `${label} ${JSON.stringify(value)}` +} + export async function checkServerHealth( server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, @@ -74,7 +89,19 @@ export async function checkServerHealth( const signal = opts?.signal ?? timeout?.signal const retryCount = opts?.retryCount ?? defaultRetryCount const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs + const logFailure = (phase: string, count: number, error: unknown) => { + console.error( + stringifyLog("[server health] request failed", { + phase, + attempt: count + 1, + url: server.url, + hasAuth: !!server.password, + error: serializeError(error), + }), + ) + } const next = (count: number, error: unknown) => { + logFailure("retry", count, error) if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) return wait(retryDelayMs * (count + 1), signal) .then(() => attempt(count + 1)) @@ -87,7 +114,10 @@ export async function checkServerHealth( signal, }) .global.health() - .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) + .then((x) => { + if (x.error) return next(count, x.error) + return { healthy: x.data?.healthy === true, version: x.data?.version } + }) .catch((error) => next(count, error)) return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index eb0b260ea..0091f35e7 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -14,13 +14,40 @@ export function resolveAppPath(appName: string): string | null { return resolveWindowsAppPath(appName) } +// Parses `\\wsl$\\...` and `\\wsl.localhost\\...` UNC paths that +// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably: +// backslashes get shell-collapsed when passed through `wsl.exe`, turning +// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`, +// which is a valid-looking path that wedges opencode on DrvFs stat calls. +function parseWslUncPath(value: string): { distro: string; subpath: string } | null { + // Normalise separators; both `\\` and `//` prefixes mean UNC. + const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//") + const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised) + if (!match) return null + const distro = match[2] + const subpath = match[3] ?? "" + return { distro, subpath } +} + export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise { if (process.platform !== "win32") return path + // `\\wsl$\\...` / `\\wsl.localhost\\...` -> `/` in + // the target distro. Do the conversion in-process rather than shelling out + // to `wslpath -u`, which mangles backslashes via wsl.exe's command-line + // joiner. If the requested distro differs from the UNC distro, we still + // translate literally — callers are responsible for only picking paths + // inside the active distro. + if (mode === "linux") { + const unc = parseWslUncPath(path) + if (unc) return `/${unc.subpath}` + } + const flag = mode === "windows" ? "-w" : "-u" try { const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path - const output = await runWslInDistro(["wslpath", flag, resolved], distro) + const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved + const output = await runWslInDistro(["wslpath", flag, input], distro) if (output.code !== 0) { throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`) } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 4ee71ba2a..a89ce6fd2 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto" import { EventEmitter } from "node:events" import { existsSync } from "node:fs" +import * as nodeHttp from "node:http" import { homedir } from "node:os" import { join } from "node:path" import type { Event } from "electron" @@ -41,7 +42,7 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server" -import { store } from "./store" +import { getStore } from "./store" import { createWslServersController } from "./wsl-servers" import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" @@ -75,11 +76,15 @@ logger.log("app starting", { version: app.getVersion(), packaged: app.isPackaged, }) +// NOTE: the first getStore() call here is intentional — it is the earliest +// point after `app.setName` / `app.setPath("userData", ...)` have run, so +// electron-store correctly resolves its root to the channel-specific +// userData dir (`...desktop.dev` in dev) rather than the package.json name. logger.log("config paths", { userData: app.getPath("userData"), - settingsStore: store.path, + settingsStore: getStore().path, wslServersKey: WSL_SERVERS_KEY, - wslServers: store.get(WSL_SERVERS_KEY) ?? null, + wslServers: getStore().get(WSL_SERVERS_KEY) ?? null, }) setupApp() @@ -330,6 +335,7 @@ function wireMenu() { } registerIpcHandlers({ + httpFetch: (input) => bridgedHttpFetch(input), killSidecar: () => killSidecar(), relaunch: () => relaunchApp(), awaitInitialization: async (sendStep) => { @@ -391,6 +397,82 @@ function relaunchApp() { app.exit(0) } +// Uses node:http directly rather than global fetch (undici). On Windows, +// undici pools keep-alive sockets across requests; the WSL2 port proxy +// silently drops idle loopback sockets, so reusing one hangs until timeout. +// `agent: false` + `Connection: close` forces a fresh TCP connection per +// request, which is the only reliable way to hit a WSL-forwarded port. +function bridgedHttpFetch(input: { + url: string + method: string + headers: Record + body?: string + timeoutMs?: number +}): Promise<{ + status: number + statusText: string + headers: Record + body: string +}> { + return new Promise((resolve, reject) => { + let parsed: URL + try { + parsed = new URL(input.url) + } catch (error) { + reject(new Error(`httpFetch: invalid url ${input.url}: ${String(error)}`)) + return + } + if (parsed.protocol !== "http:") { + reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`)) + return + } + + const req = nodeHttp.request({ + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : 80, + path: `${parsed.pathname}${parsed.search}`, + method: input.method, + headers: { ...input.headers, connection: "close" }, + agent: false, + }) + + const timeoutMs = input.timeoutMs ?? 15_000 + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`httpFetch: timeout after ${timeoutMs}ms (${input.method} ${input.url})`)) + }) + + req.once("error", (error) => { + const err = error as NodeJS.ErrnoException + const detail = [err.name, err.code, err.message].filter(Boolean).join(" | ") + reject(new Error(`httpFetch: ${detail || "unknown error"}`)) + }) + + req.once("response", (res) => { + const chunks: Buffer[] = [] + res.on("data", (chunk: Buffer) => chunks.push(chunk)) + res.once("end", () => { + const headers: Record = {} + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) continue + headers[key] = Array.isArray(value) ? value.join(", ") : String(value) + } + resolve({ + status: res.statusCode ?? 0, + statusText: res.statusMessage ?? "", + headers, + body: Buffer.concat(chunks).toString("utf8"), + }) + }) + res.once("error", (error) => { + reject(new Error(`httpFetch response error: ${String(error)}`)) + }) + }) + + if (input.body !== undefined) req.write(input.body) + req.end() + }) +} + function ensureLoopbackNoProxy() { const loopback = ["127.0.0.1", "localhost", "::1"] const upsert = (key: string) => { diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index c6d2c4fac..54ef70492 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -21,6 +21,18 @@ const pickerFilters = (ext?: string[]) => { } type Deps = { + httpFetch: (input: { + url: string + method: string + headers: Record + body?: string + timeoutMs?: number + }) => Promise<{ + status: number + statusText: string + headers: Record + body: string + }> killSidecar: () => void relaunch: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise @@ -56,6 +68,13 @@ type Deps = { } export function registerIpcHandlers(deps: Deps) { + const debugStore = (op: string, name: string, key: string, meta?: Record) => { + if (app.isPackaged) return + if (!name.startsWith("opencode.workspace.")) return + if (!key.includes("terminal")) return + console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`) + } + const offWslServers = deps.onWslServersEvent((payload) => { for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue @@ -64,6 +83,13 @@ export function registerIpcHandlers(deps: Deps) { }) app.once("will-quit", offWslServers) + ipcMain.handle( + "http-fetch", + ( + _event: IpcMainInvokeEvent, + input: { url: string; method: string; headers: Record; body?: string; timeoutMs?: number }, + ) => deps.httpFetch(input), + ) ipcMain.handle("kill-sidecar", () => deps.killSidecar()) ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => { const send = (step: InitStep) => event.sender.send("init-step", step) @@ -122,13 +148,24 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { const store = getStore(name) const value = store.get(key) + debugStore("get", name, key, { + found: value !== undefined && value !== null, + length: + typeof value === "string" + ? value.length + : value === undefined || value === null + ? 0 + : JSON.stringify(value).length, + }) if (value === undefined || value === null) return null return typeof value === "string" ? value : JSON.stringify(value) }) ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => { + debugStore("set", name, key, { length: value.length }) getStore(name).set(key, value) }) ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => { + debugStore("delete", name, key) getStore(name).delete(key) }) ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => { diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index 70e3dc9c7..2c0b25b6a 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -67,7 +67,8 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { - if (getStore().get(TAURI_MIGRATED_KEY)) { + const store = getStore() + if (store.get(TAURI_MIGRATED_KEY)) { log.log("tauri migration: already done, skipping") return } diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 8bfd19ef2..8d65ee4aa 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -10,11 +10,18 @@ import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl" export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | null { - const value = getStore().get(DEFAULT_SERVER_URL_KEY) - return typeof value === "string" ? value : null + const store = getStore() + const value = store.get(DEFAULT_SERVER_URL_KEY) + if (typeof value !== "string") return null + if (value === "sidecar") { + store.set(DEFAULT_SERVER_URL_KEY, "local:windows") + return "local:windows" + } + return value } export function setDefaultServerUrl(url: string | null) { + const store = getStore() if (url) { getStore().set(DEFAULT_SERVER_URL_KEY, url) return @@ -73,7 +80,7 @@ export async function spawnLocalServer(hostname: string, port: number, password: } export type WslSidecar = { - listener: { stop: () => void } + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } url: string username: string | null password: string @@ -98,16 +105,44 @@ export async function spawnWslSidecar( const port = await allocatePort() const password = randomUUID() const username = "opencode" + const logLevel = app.isPackaged ? "WARN" : "INFO" const script = [ "set -euo pipefail", - "export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true", - "export OPENCODE_EXPERIMENTAL_FILEWATCHER=true", + // wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it + // to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's + // instance middleware falls back to `process.cwd()` when a request + // arrives without a `directory=` query or `x-opencode-directory` header + // (see opencode server.ts InstanceMiddleware), and then calls + // `realpathSync(process.cwd())` synchronously on the main thread. A + // statx against a 9p path can wedge the whole event loop in kernel + // uninterruptible sleep, freezing the accept loop. Move cwd to the + // user's native Linux home so the fallback can't land on DrvFs. + 'cd "$HOME" || cd /', + // wsl.exe by default splices the Windows %PATH% into the distro's $PATH + // via the interop layer (every `/mnt/c/Program Files/...` entry). Anything + // the sidecar spawns — PTY login shells, plugin helpers, etc. — then + // inherits it, which means `which pwsh.exe` resolves to the Windows + // PowerShell binary and bash-l profiles that end with + // eval "$(oh-my-posh init bash)" (or similar) + // silently run Windows pwsh for prompt rendering, whose banner + // ("Loading personal and system profiles took Xms.") then shows up in + // opencode's terminal pane. We want a clean, Linux-only environment in + // the sidecar, so filter every /mnt/* segment out of PATH and clear + // WSLENV so no further Windows vars leak in. Users who really need + // Windows binaries in the sidecar can invoke them by absolute path. + 'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")', + "export PATH", + "export WSLENV=", + // WSL sidecars often target /mnt/* worktrees. Keep the desktop-only + // watcher/discovery features off there because DrvFs/9p stalls can wedge + // the server process after it starts listening. + "export OPENCODE_EXPERIMENTAL_DISABLE_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}`, + `exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`, ].join("\n") const child = spawn("wsl", wslArgs(["bash", "-se"], distro), { @@ -166,6 +201,9 @@ export async function spawnWslSidecar( stop() { child.kill() }, + onExit(cb) { + child.once("exit", cb) + }, }, url, username, diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index 61f0c0a49..b65f20a85 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,10 +4,12 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() -// We cannot instantiate the electron-store at module load time because -// module import hoisting causes this to run before app.setPath("userData", ...) -// in index.ts has executed, which would result in files being written to the default directory -// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). +// IMPORTANT: do NOT construct Store at module import time. electron-store +// resolves `app.getPath("userData")` in its constructor, but our index.ts +// only calls `app.setName` / `app.setPath("userData", ...)` AFTER module +// imports finish. Constructing eagerly wrote settings (e.g. the WSL server +// config) to the default `%APPDATA%\@opencode-ai\desktop-electron` folder +// instead of the proper `...desktop.dev` / channel dir. export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached diff --git a/packages/desktop-electron/src/main/wsl-pty.ts b/packages/desktop-electron/src/main/wsl-pty.ts new file mode 100644 index 000000000..7f8bdfa6d --- /dev/null +++ b/packages/desktop-electron/src/main/wsl-pty.ts @@ -0,0 +1,126 @@ +/** @ts-expect-error */ +import * as pty from "@lydell/node-pty" +import type { RunWslOptions, WslCommandResult } from "./wsl" + +export function runInteractiveCommand( + command: string, + args: string[], + opts: RunWslOptions = {}, + defaultTimeoutMs: number, +) { + return new Promise((resolve, reject) => { + const child = pty.spawn(command, args, { + name: "xterm-color", + cols: 80, + rows: 24, + cwd: process.cwd(), + env: process.env, + useConpty: true, + }) + + let settled = false + const parser = createInteractiveOutputParser((text) => opts.onLine?.({ stream: "stdout", text })) + let stdout = "" + + const cleanup = () => { + clearTimeout(timeoutId) + abortCleanup?.() + parser.flush() + } + + const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs + const timeoutId = setTimeout(() => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + const abortHandler = () => { + try { + child.kill() + } catch { + /* ignore */ + } + if (settled) return + settled = true + cleanup() + reject(new DOMException("Aborted", "AbortError")) + } + const abortCleanup = opts.signal + ? (() => { + opts.signal?.addEventListener("abort", abortHandler, { once: true }) + return () => opts.signal?.removeEventListener("abort", abortHandler) + })() + : undefined + + child.onData((data: string) => { + stdout += data + parser.write(data) + }) + child.onExit((event: { exitCode: number }) => { + if (settled) return + settled = true + cleanup() + resolve({ code: event.exitCode, signal: null, stdout, stderr: "" }) + }) + }) +} + +function createInteractiveOutputParser(onLine: (line: string) => void) { + let line = "" + let escape = "" + let lastProgress = "" + + const emit = (value: string) => { + const text = value.trim() + if (!text) return + if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text)) { + if (text === lastProgress) return + lastProgress = text + } + onLine(text) + } + + return { + write(chunk: string) { + for (const char of chunk) { + if (escape) { + escape += char + const isCsi = escape.startsWith("\u001b[") + const isOsc = escape.startsWith("\u001b]") + if ((isCsi && /[@-~]/.test(char)) || (isOsc && char === "\u0007") || escape.endsWith("\u001b\\")) { + escape = "" + } else if (!isCsi && !isOsc && escape.length > 1) { + escape = "" + } + continue + } + if (char === "\u001b") { + escape = "\u001b" + continue + } + if (char === "\b" || char === "\u007f") { + line = line.slice(0, -1) + continue + } + if (char === "\r" || char === "\n") { + emit(line) + line = "" + continue + } + line += char + if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(line)) emit(line) + } + }, + flush() { + emit(line) + line = "" + }, + } +} diff --git a/packages/desktop-electron/src/main/wsl-servers.ts b/packages/desktop-electron/src/main/wsl-servers.ts index c35e4e52b..9c9dc79e8 100644 --- a/packages/desktop-electron/src/main/wsl-servers.ts +++ b/packages/desktop-electron/src/main/wsl-servers.ts @@ -15,7 +15,7 @@ import type { } from "../preload/types" import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants" import { spawnWslSidecar } from "./server" -import { store } from "./store" +import { getStore } from "./store" import type { WslCommandLine } from "./wsl" import { installWslDistro, @@ -33,7 +33,7 @@ import { } from "./wsl" type RunningSidecar = { - listener: { stop: () => void } + listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } url: string username: string | null password: string @@ -64,19 +64,29 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa for (const listener of listeners) listener({ type: "state", state }) } + const isProgressLine = (text: string) => { + return text.includes("[") && text.includes("]") && /(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text) + } + const setState = (next: Partial) => { state = { ...state, ...next } emit() } const appendTranscript = (line: Omit) => { - setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] }) + const next = { ...line, at: Date.now() } + const last = state.transcript.at(-1) + if (last && last.stream === line.stream && isProgressLine(last.text) && isProgressLine(line.text)) { + setState({ transcript: [...state.transcript.slice(0, -1), next] }) + return + } + setState({ transcript: [...state.transcript, next] }) } const clearTranscript = () => setState({ transcript: [] }) const persistServers = (servers: WslServerConfig[]) => { - store.set(WSL_SERVERS_KEY, { servers }) + getStore().set(WSL_SERVERS_KEY, { servers }) } const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => { @@ -117,6 +127,30 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa updateServer(id, (item) => ({ ...item, runtime })) } + const removeMissingServer = (id: string) => { + const remaining = readPersistedServers().filter((item) => item.id !== id) + persistServers(remaining) + setState({ servers: state.servers.filter((item) => item.config.id !== id) }) + } + + const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => { + setState({ + opencodeChecks: { + ...state.opencodeChecks, + [distro]: check, + }, + }) + } + + const refreshOpencodeCheck = async ( + distro: string, + opts?: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void }, + ) => { + const resolved = await resolveWslOpencode(distro, opts) + const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null + setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion)) + } + const nextStartAttempt = (id: string) => { const next = (startAttempts.get(id) ?? 0) + 1 startAttempts.set(id, next) @@ -156,10 +190,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa username: sidecar.username, password: sidecar.password, }) + sidecar.listener.onExit((code, signal) => { + if (sidecars.get(id) !== sidecar) return + sidecars.delete(id) + const message = startupFailure(code, signal) + setRuntime(id, { kind: "failed", message }) + mainLogger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal }) + }) + void refreshOpencodeCheck(item.config.distro).catch((error) => { + const message = error instanceof Error ? error.message : String(error) + mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message }) + }) 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 + if (isMissingDistroError(message)) { + removeMissingServer(id) + mainLogger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message }) + 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 @@ -171,12 +221,12 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa const stopServerInternal = async (id: string) => { const existing = sidecars.get(id) if (!existing) return + sidecars.delete(id) try { existing.listener.stop() } catch { // ignore stop errors } - sidecars.delete(id) } const runJob = async (job: WslJob, runner: (abort: AbortController) => Promise) => { @@ -285,14 +335,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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), - }, - }) + await refreshOpencodeCheck(name, { signal: abort.signal, onLine }) }) }, @@ -310,16 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa 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), - }, - }) + await refreshOpencodeCheck(name, { signal: abort.signal, onLine }) }) }, @@ -408,6 +442,7 @@ function initialState(): WslServersState { } function readPersistedServers(): WslServerConfig[] { + const store = getStore() const existing = store.get(WSL_SERVERS_KEY) if (existing && typeof existing === "object") { const record = existing as { servers?: unknown } @@ -420,7 +455,7 @@ function readPersistedServers(): WslServerConfig[] { } function migrateLegacyLocalServer(): WslServerConfig[] { - const legacy = store.get(LEGACY_LOCAL_SERVER_KEY) + const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY) if (!legacy || typeof legacy !== "object") return [] const record = legacy as Record if (record.mode !== "wsl") return [] @@ -507,6 +542,14 @@ function summarize(value: string) { .join("\n") } +function isMissingDistroError(message: string) { + return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message) +} + +function startupFailure(code: number | null, signal: NodeJS.Signals | null) { + return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})` +} + // Re-export types used by callers export type { WslInstalledDistro, diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 07a22b825..1785df0f1 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types" +import { runInteractiveCommand } from "./wsl-pty" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -13,7 +14,7 @@ export type WslCommandResult = { stderr: string } -type RunWslOptions = { +export type RunWslOptions = { onLine?: (line: WslCommandLine) => void signal?: AbortSignal /** @@ -28,6 +29,7 @@ type RunWslOptions = { } const DEFAULT_WSL_TIMEOUT_MS = 20_000 +const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000 // `--user root` bypasses the distro's default-user requirement. A freshly // installed WSL distro (Ubuntu-24.04 in particular) prompts interactively @@ -89,21 +91,37 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { return "" } + const splitOutput = (pending: string) => { + const lines: string[] = [] + let start = 0 + for (let i = 0; i < pending.length; i++) { + const char = pending[i] + if (char !== "\r" && char !== "\n") continue + lines.push(pending.slice(start, i)) + if (char === "\r" && pending[i + 1] === "\n") i += 1 + start = i + 1 + } + return { + lines, + pending: pending.slice(start), + } + } + 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 }) + const next = splitOutput(stdoutPending) + stdoutPending = next.pending + for (const line of next.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 }) + const next = splitOutput(stderrPending) + stderrPending = next.pending + for (const line of next.lines) opts.onLine?.({ stream: "stderr", text: line }) } child.stdout.on("data", (chunk: Buffer) => { @@ -288,7 +306,7 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) { } export async function installWslRuntime(opts?: RunWslOptions) { - return runWsl(["--install", "--no-distribution"], opts) + return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) } export async function installWslRuntimeElevated(opts?: RunWslOptions) { @@ -297,18 +315,23 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) { "$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) + return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS)) } export async function installWslDistro(name: string, opts?: RunWslOptions) { - return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) + return runInteractiveCommand( + "wsl", + ["--install", "-d", name, "--web-download", "--no-launch"], + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + DEFAULT_WSL_INSTALL_TIMEOUT_MS, + ) } 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, + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), ) } @@ -420,10 +443,17 @@ export async function readWslCommandVersion(command: string, distro: string, opt } export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) { - return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts) + return runWslBash( + `${shellEscape(command)} upgrade ${shellEscape(target)}`, + distro, + withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS), + ) } export function openWslTerminal(distro?: string | null) { + if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) { + return Promise.reject(new Error("Invalid distro name")) + } return new Promise((resolve, reject) => { const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], { detached: true, @@ -489,3 +519,10 @@ function summarize(value: string) { function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } + +function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions { + return { + ...opts, + timeoutMs: opts?.timeoutMs ?? timeoutMs, + } +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index faf0d692c..ea8675a87 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from "electron" import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types" const api: ElectronAPI = { + httpFetch: (input) => ipcRenderer.invoke("http-fetch", input), killSidecar: () => ipcRenderer.invoke("kill-sidecar"), installCli: () => ipcRenderer.invoke("install-cli"), awaitInitialization: (onStep) => { diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index 18183868a..98e61a66c 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -125,6 +125,18 @@ export type TitlebarTheme = { } export type ElectronAPI = { + httpFetch: (input: { + url: string + method: string + headers: Record + body?: string + timeoutMs?: number + }) => Promise<{ + status: number + statusText: string + headers: Record + body: string + }> killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 7aae90348..f7342e9be 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -70,7 +70,7 @@ import { } from "@opencode-ai/app" import type { AsyncStorage } from "@solid-primitives/storage" import { MemoryRouter } from "@solidjs/router" -import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" @@ -140,6 +140,48 @@ const createPlatform = (): Platform => { return window.api.wslPath("~", "windows", distro).catch(() => undefined) } + // SSE endpoints must keep a live connection; IPC-bridged fetch buffers the + // whole response body in main before returning, which breaks streams. + const isStreamingPath = (pathname: string) => + pathname.endsWith("/event") || pathname === "/global/event" || pathname.endsWith("/pty/read") + + // Chromium's network stack on Windows frequently stalls on WSL2-forwarded + // loopback ports (happy-eyeballs to [::1] hits the WSL port proxy which + // only binds v4). Node/undici in main has no such issue, so we route WSL + // loopback requests through the main process. `localhost`/`[::1]` are also + // loopback spellings we need to catch. + const isLoopback = (hostname: string) => + hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]" + + const shouldBridge = (url: URL) => { + if (!activeWslDistro()) return false + if (url.protocol !== "http:") return false + if (!isLoopback(url.hostname)) return false + if (isStreamingPath(url.pathname)) return false + return true + } + + const bridgedFetch = async (request: Request, timeoutMs?: number) => { + const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.clone().text() + const res = await window.api.httpFetch({ + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + body, + timeoutMs, + }) + // Null-body statuses (101/204/205/304) must be constructed with a null + // body or the Response constructor throws `Response with null body + // status cannot have body`. The IPC layer always hands us `res.body` as + // a string, so coerce to null for these statuses. + const nullBody = res.status === 101 || res.status === 204 || res.status === 205 || res.status === 304 + return new Response(nullBody ? null : res.body, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }) + } + const handleWslPicker = async (result: T | null): Promise => { const distro = activeWslDistro() if (!result || !distro) return result @@ -272,8 +314,47 @@ const createPlatform = (): Platform => { }, fetch: (input, init) => { - if (input instanceof Request) return fetch(input) - return fetch(input, init) + const request = input instanceof Request ? (init ? new Request(input, init) : input) : new Request(input, init) + const url = (() => { + try { + return new URL(request.url, location.href) + } catch { + return null + } + })() + if (!url || !shouldBridge(url)) { + if (input instanceof Request && !init) return fetch(input) + return fetch(request) + } + // Propagate the request's own abort signal to the bridge via a finite + // timeout. If nothing set one we default to 15s so connects can't hang + // forever waiting on a dead WSL port proxy. + const signal = request.signal + const timeoutMs = 15_000 + return new Promise((resolve, reject) => { + let settled = false + const onAbort = () => { + if (settled) return + settled = true + reject(new DOMException("Aborted", "AbortError")) + } + if (signal?.aborted) return onAbort() + signal?.addEventListener("abort", onAbort, { once: true }) + bridgedFetch(request, timeoutMs).then( + (res) => { + if (settled) return + settled = true + signal?.removeEventListener("abort", onAbort) + resolve(res) + }, + (err) => { + if (settled) return + settled = true + signal?.removeEventListener("abort", onAbort) + reject(err) + }, + ) + }) }, getDefaultServer: async () => { @@ -368,7 +449,7 @@ render(() => { onCleanup(off) } - const servers = () => { + const servers = createMemo(() => { const data = startup.latest?.sidecar const list: ServerConnection.Any[] = [] if (data) { @@ -398,7 +479,7 @@ render(() => { url: `http://wsl-${item.config.distro}.invalid`, } list.push({ - displayName: `WSL: ${item.config.distro}`, + displayName: item.config.distro, type: "sidecar", variant: "wsl", distro: item.config.distro, @@ -407,7 +488,7 @@ render(() => { } } return list - } + }) function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 070d0c71f..d6ed08644 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -24,7 +24,20 @@ pub fn get_default_server_url(app: AppHandle) -> Result, String> let value = store.get(DEFAULT_SERVER_URL_KEY); match value { - Some(v) => Ok(v.as_str().map(String::from)), + Some(v) => match v.as_str() { + Some("sidecar") => { + store.set( + DEFAULT_SERVER_URL_KEY, + serde_json::Value::String("local:windows".to_string()), + ); + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + Ok(Some("local:windows".to_string())) + } + Some(value) => Ok(Some(value.to_string())), + None => Ok(None), + }, None => Ok(None), } }