mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
clean maybe
This commit is contained in:
@@ -256,9 +256,23 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
|
||||
size="large"
|
||||
class="mt-4"
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-server").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectServer />)
|
||||
})
|
||||
void import("@/components/dialog-select-server")
|
||||
.then((x) => {
|
||||
dialog.show(() => (
|
||||
<x.DialogSelectServer
|
||||
onNavigateHome={() => {
|
||||
// We're above the Router here so useNavigate() isn't available.
|
||||
// Update the browser URL directly; after server.setActive fires
|
||||
// ServerKey remounts the Router which picks up "/" on init.
|
||||
// Harmless under MemoryRouter (Electron), which restarts at "/".
|
||||
if (typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})
|
||||
.catch((err) => console.error("Failed to load server dialog", err))
|
||||
}}
|
||||
>
|
||||
Manage servers
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { WslServerStep, WslServersState } from "@/context/platform"
|
||||
@@ -125,14 +125,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
if (!distroReady()) return "distro"
|
||||
return "opencode"
|
||||
})
|
||||
// activeStep falls back to recommendedStep when the user hasn't picked one.
|
||||
// Once the user clicks a step tab we respect their choice rather than snapping
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
|
||||
createEffect(
|
||||
on(recommendedStep, (next) => {
|
||||
setStore("step", next)
|
||||
}),
|
||||
)
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || !wslServers() || busy()) return null
|
||||
@@ -157,8 +154,19 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
createEffect(() => {
|
||||
const probe = autoProbe()
|
||||
if (!probe || probe.key === lastAutoProbe) return
|
||||
lastAutoProbe = probe.key
|
||||
void run(probe.run)
|
||||
const key = probe.key
|
||||
lastAutoProbe = key
|
||||
void (async () => {
|
||||
try {
|
||||
await probe.run()
|
||||
} catch (err) {
|
||||
// Allow the same probe to run again when reactive inputs next change
|
||||
// (e.g. user reselects a distro). Without this the user would be stuck
|
||||
// on a transient wsl.exe failure until they pick a different distro.
|
||||
if (lastAutoProbe === key) lastAutoProbe = null
|
||||
requestError(language, err)
|
||||
}
|
||||
})()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
import { wslArgs } from "./wsl"
|
||||
import { resolveWslHome, runWslInDistro } from "./wsl"
|
||||
|
||||
export function checkAppExists(appName: string): boolean {
|
||||
if (process.platform === "win32") return true
|
||||
@@ -14,20 +14,17 @@ export function resolveAppPath(appName: string): string | null {
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): string {
|
||||
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
|
||||
if (process.platform !== "win32") return path
|
||||
|
||||
const flag = mode === "windows" ? "-w" : "-u"
|
||||
try {
|
||||
if (path.startsWith("~")) {
|
||||
const suffix = path.slice(1)
|
||||
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
|
||||
const output = execFileSync("wsl", wslArgs(["sh", "-lc", cmd], distro))
|
||||
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", wslArgs(["wslpath", flag, path], distro))
|
||||
return output.toString().trim()
|
||||
return output.stdout.trim()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||
}
|
||||
|
||||
@@ -56,23 +56,20 @@ const pendingDeepLinks: string[] = []
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
void serverReady.promise.catch(() => undefined)
|
||||
const wslServers = (() => {
|
||||
const logger = initLogging()
|
||||
return 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),
|
||||
},
|
||||
)
|
||||
})()
|
||||
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(),
|
||||
|
||||
@@ -57,6 +57,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
|
||||
const emit = () => {
|
||||
@@ -116,14 +117,38 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
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",
|
||||
@@ -134,6 +159,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
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
|
||||
@@ -327,6 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
},
|
||||
|
||||
async removeServer(id: string) {
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
const remaining = readPersistedServers().filter((item) => item.id !== id)
|
||||
persistServers(remaining)
|
||||
@@ -336,6 +363,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
startServer,
|
||||
|
||||
async stopServer(id: string) {
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
setRuntime(id, { kind: "stopped" })
|
||||
},
|
||||
@@ -350,6 +378,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
},
|
||||
|
||||
stopAll() {
|
||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
||||
for (const [id] of sidecars) {
|
||||
const existing = sidecars.get(id)
|
||||
try {
|
||||
|
||||
@@ -353,11 +353,55 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
@@ -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,11 +13,19 @@ export interface DialogProps extends ParentProps {
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
fit?: boolean
|
||||
transition?: boolean
|
||||
// When `false`, clicking the overlay or outside the dialog will not dismiss it.
|
||||
// Default is `true`.
|
||||
dismissOutside?: boolean
|
||||
}
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
const i18n = useI18n()
|
||||
const dialogCtx = useContext(DialogContext)
|
||||
createEffect(() => {
|
||||
if (!dialogCtx) return
|
||||
if (props.dismissOutside === undefined) return
|
||||
dialogCtx.active?.setDismissOutside(props.dismissOutside)
|
||||
})
|
||||
return (
|
||||
<div
|
||||
data-component="dialog"
|
||||
@@ -32,12 +41,6 @@ export function Dialog(props: DialogProps) {
|
||||
...props.classList,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
if (props.dismissOutside === false) e.preventDefault()
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (props.dismissOutside === false) e.preventDefault()
|
||||
}}
|
||||
onOpenAutoFocus={(e) => {
|
||||
const target = e.currentTarget as HTMLElement | null
|
||||
const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
|
||||
|
||||
@@ -23,10 +23,14 @@ type Active = {
|
||||
owner: Owner
|
||||
onClose?: () => void
|
||||
setClosing: (closing: boolean) => void
|
||||
dismissOutside: () => boolean
|
||||
setDismissOutside: (value: boolean) => void
|
||||
}
|
||||
|
||||
const Context = createContext<ReturnType<typeof init>>()
|
||||
|
||||
export const DialogContext = Context
|
||||
|
||||
function init() {
|
||||
const [active, setActive] = createSignal<Active | undefined>()
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -89,12 +93,17 @@ function init() {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
let dispose: (() => void) | undefined
|
||||
let setClosing: ((closing: boolean) => void) | undefined
|
||||
let setDismissOutsideSignal: ((value: boolean) => void) | undefined
|
||||
let dismissOutsideAccessor: (() => boolean) | undefined
|
||||
|
||||
const node = runWithOwner(owner, () =>
|
||||
createRoot((d: () => void) => {
|
||||
dispose = d
|
||||
const [closing, setClosingSignal] = createSignal(false)
|
||||
setClosing = setClosingSignal
|
||||
const [dismissOutside, setDismissOutside] = createSignal(true)
|
||||
dismissOutsideAccessor = dismissOutside
|
||||
setDismissOutsideSignal = setDismissOutside
|
||||
return (
|
||||
<Kobalte
|
||||
modal
|
||||
@@ -105,7 +114,12 @@ function init() {
|
||||
}}
|
||||
>
|
||||
<Kobalte.Portal>
|
||||
<Kobalte.Overlay data-component="dialog-overlay" />
|
||||
<Kobalte.Overlay
|
||||
data-component="dialog-overlay"
|
||||
onClick={() => {
|
||||
if (dismissOutside()) close()
|
||||
}}
|
||||
/>
|
||||
{element()}
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
@@ -113,9 +127,18 @@ function init() {
|
||||
}),
|
||||
)
|
||||
|
||||
if (!dispose || !setClosing) return
|
||||
if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return
|
||||
|
||||
setActive({ id, node, dispose, owner, onClose, setClosing })
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
138
plan.md
138
plan.md
@@ -1,138 +0,0 @@
|
||||
# WSL Server Implementation Backlog
|
||||
|
||||
This backlog assumes Electron only.
|
||||
It is ordered chronologically.
|
||||
Each task is intended to be small enough for a new engineer to pick up directly.
|
||||
|
||||
## Direction
|
||||
|
||||
- Local Server is **always** Windows-native local on Windows.
|
||||
- Local Server has no runtime swap and no WSL mode.
|
||||
- WSL servers are a **separate, additive** concept.
|
||||
- A Windows user can add zero or more WSL servers; each is bound to a specific distro.
|
||||
- Each WSL server runs as its own sidecar alongside the Windows Local Server.
|
||||
- Adding/removing a WSL server is hot; no app restart required.
|
||||
- Manage Servers UI exposes an `Add WSL` button (Windows only) that opens the wizard.
|
||||
|
||||
## 01 Electron Config Split
|
||||
|
||||
- [x] Remove runtime `mode` / `distro` from the persisted Local Server config.
|
||||
- [x] Introduce a new persisted `wslServers` key holding an array of `WslServerConfig`.
|
||||
- [x] Define `WslServerConfig` as `{ id, distro, onboarding, acknowledgements }`.
|
||||
- [x] Keep onboarding metadata per WSL server, not globally.
|
||||
- [x] Keep acknowledgement state per WSL server.
|
||||
- [x] Migrate any legacy `localServer.mode === "wsl"` entry into a single `wslServers` entry on read.
|
||||
- [x] Drop all references to `LocalServerMode` from the preload types.
|
||||
|
||||
## 02 Main-Process Multi-Sidecar Startup
|
||||
|
||||
- [x] Always start the Windows local sidecar on app launch.
|
||||
- [x] After Windows local is spawned, iterate each `WslServerConfig` and spawn a WSL sidecar per entry.
|
||||
- [x] Give each WSL sidecar its own port and password.
|
||||
- [x] Allocate the Windows local port/password once per launch (unchanged).
|
||||
- [x] Track all sidecars in a single map keyed by server id.
|
||||
- [x] Kill all sidecars on `before-quit` / `will-quit` / signal.
|
||||
- [x] Include Windows local data in the startup payload unchanged.
|
||||
- [x] Include the initial set of WSL servers (with url/password/status) in the startup payload.
|
||||
- [x] Emit per-WSL-server lifecycle events (`starting`, `ready`, `failed`, `stopping`, `removed`).
|
||||
- [x] On startup, do not block the main window on WSL sidecar health.
|
||||
- [x] If a WSL sidecar fails health, keep it in the list and mark it failed instead of hanging startup.
|
||||
|
||||
## 03 WSL Server Controller
|
||||
|
||||
- [x] Create a main-process controller that owns `wslServers` persistence and runtime state.
|
||||
- [x] Expose typed events (`state`, per-item status changes) from the controller.
|
||||
- [x] Support one in-flight job per WSL server (not a global in-flight job).
|
||||
- [x] Implement `addServer(distro)` that persists, then spawns and health-checks a new sidecar.
|
||||
- [x] Implement `removeServer(id)` that stops the sidecar and removes it from config.
|
||||
- [x] Implement per-server `runStep`, `cancelJob`, `installWsl`, `installDistro`, `installOpencode`, `openTerminal`.
|
||||
- [x] Reuse the existing WSL process helpers unchanged.
|
||||
- [x] Keep transcripts per server, only for the current app launch.
|
||||
|
||||
## 04 IPC / Preload Surface
|
||||
|
||||
- [x] Rename `localServer.*` IPC channels to `wslServers.*`.
|
||||
- [x] Add `wslServers.getState()` returning the full list plus per-server runtime info.
|
||||
- [x] Add `wslServers.subscribe()` with unsubscribe support.
|
||||
- [x] Add `wslServers.add(distro)` (persists config + starts sidecar).
|
||||
- [x] Add `wslServers.remove(id)`.
|
||||
- [x] Add `wslServers.runStep(id, step)`.
|
||||
- [x] Add `wslServers.cancelJob(id)`.
|
||||
- [x] Add `wslServers.installWsl(id)`.
|
||||
- [x] Add `wslServers.installDistro(id, distro)`.
|
||||
- [x] Add `wslServers.installOpencode(id)`.
|
||||
- [x] Add `wslServers.openTerminal(id)`.
|
||||
- [x] Remove obsolete `localServer.setConfig` / `localServer.*` channels.
|
||||
- [x] Include url/username/password for each WSL server in the state payload (after sidecar start).
|
||||
|
||||
## 05 Renderer Platform Wiring
|
||||
|
||||
- [x] Expose `platform.wslServers` as a reactive accessor (list + subscribe).
|
||||
- [x] Remove `platform.localServer` runtime swap APIs.
|
||||
- [x] Keep `platform.wslServers` API available on Windows only.
|
||||
- [x] Keep distro-aware path conversion keyed by the active WSL server.
|
||||
- [x] When the active server is a WSL sidecar, default pickers to that distro's home.
|
||||
- [x] When the active server is the Windows Local Server, keep native Windows picker defaults.
|
||||
|
||||
## 06 Renderer Server List
|
||||
|
||||
- [x] Always include the Windows Local Server in the server list.
|
||||
- [x] Include each configured WSL server in the server list with `ServerConnection.Sidecar` variant `wsl`.
|
||||
- [x] Keep `ServerConnection.key` returning `local:windows` for the Windows Local Server.
|
||||
- [x] Keep `ServerConnection.key` returning `wsl:<distro>` for WSL servers (one per distro).
|
||||
- [x] Keep distinct `projectsKey` buckets for Windows local vs each WSL server.
|
||||
- [x] Do not collapse a WSL server into the `local` projects bucket.
|
||||
|
||||
## 07 Manage Servers UI
|
||||
|
||||
- [x] Remove `Swap to WSL` and `Swap to Windows` buttons from the Local Server row.
|
||||
- [x] Show the Local Server row exactly like any other server (health, name, active check).
|
||||
- [x] Add an `Add WSL` button next to `Add server`, visible only on Windows when the platform supports WSL.
|
||||
- [x] `Add WSL` opens the same wizard stepper, scoped to a new WSL server draft.
|
||||
- [x] Each WSL server row behaves like a sidecar entry (selectable, default-able, removable).
|
||||
- [x] Add a `Remove` action to the WSL server row menu.
|
||||
- [x] Add a `Retry setup` action when a WSL server is unhealthy.
|
||||
|
||||
## 08 Add WSL Wizard
|
||||
|
||||
- [x] Replace the "Switch" step with a `Done` step.
|
||||
- [x] Step order becomes `WSL -> Distro -> OpenCode -> Done`.
|
||||
- [x] On `Done`, persist the new WSL server, start the sidecar, and close the dialog.
|
||||
- [x] If the user cancels, do not persist anything.
|
||||
- [x] Allow resuming an incomplete WSL server wizard from Manage Servers.
|
||||
- [x] Remove restart-to-apply copy, restart toasts, and "Use Windows" CTA.
|
||||
- [x] Keep failure-only diagnostics panel behavior.
|
||||
|
||||
## 09 Per-WSL Onboarding State
|
||||
|
||||
- [x] Keep `WslServerConfig.onboarding` per server.
|
||||
- [x] Keep `WslServerConfig.acknowledgements` per server.
|
||||
- [x] Resume the wizard for any server where `onboarding.complete === false`.
|
||||
- [x] Mark onboarding complete only after the sidecar becomes healthy.
|
||||
|
||||
## 10 Connection Error Path
|
||||
|
||||
- [x] If the active server is a WSL sidecar and health fails, offer `Open setup` that deep-links into the wizard for that server.
|
||||
- [x] Keep behavior unchanged for the Windows Local Server.
|
||||
- [x] Keep behavior unchanged for remote HTTP servers.
|
||||
|
||||
## 11 Legacy Removal
|
||||
|
||||
- [x] Remove `Swap to WSL` / `Swap to Windows` popover components.
|
||||
- [x] Remove `restart-to-apply` banner copy and helpers from `dialog-local-server.tsx`.
|
||||
- [x] Remove legacy `wslEnabled` preload shims.
|
||||
- [x] Remove the old `DialogSelectServer` `initialTargetMode` prop.
|
||||
- [x] Drop `localServerKey(config)` distinguishing `wsl` vs `windows` in the controller (local is always Windows).
|
||||
|
||||
## 12 Verification
|
||||
|
||||
- [ ] Verify the Windows Local Server starts unchanged on app launch.
|
||||
- [ ] Verify `Add WSL` opens the wizard with the default installed distro preselected.
|
||||
- [ ] Verify adding a WSL server spawns a new sidecar without restarting the app.
|
||||
- [ ] Verify removing a WSL server stops the sidecar and removes it from the list.
|
||||
- [ ] Verify multiple WSL servers can coexist, one per distro.
|
||||
- [ ] Verify Windows Local Server stays active while WSL sidecars come and go.
|
||||
- [ ] Verify a failed WSL sidecar does not block app startup or window creation.
|
||||
- [ ] Verify the `ConnectionError` deep-link reaches the right wizard scope.
|
||||
- [ ] Verify legacy `localServer.mode === "wsl"` persisted config migrates into `wslServers` on first launch.
|
||||
- [ ] Verify project histories stay separate per server key.
|
||||
Reference in New Issue
Block a user