From fcb4bc1b3a8fc12a5ed62a627e0c449bb3fbee8e Mon Sep 17 00:00:00 2001
From: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Date: Mon, 4 May 2026 11:20:57 +1000
Subject: [PATCH] deslop
---
bun.lock | 4 +-
nix/hashes.json | 8 +-
package.json | 2 +-
packages/app/src/app.tsx | 135 ++-
.../src/components/dialog-select-server.tsx | 45 +-
packages/app/src/components/prompt-input.tsx | 6 +-
.../src/components/session/session-header.tsx | 33 +-
.../src/components/status-popover-body.tsx | 90 +-
packages/app/src/components/terminal.tsx | 136 +--
packages/app/src/context/global-sdk.tsx | 19 +-
.../src/context/global-sync/child-store.ts | 9 +-
packages/app/src/context/platform.tsx | 6 +
packages/app/src/context/prompt.tsx | 9 +-
packages/app/src/context/server.tsx | 92 +-
packages/app/src/context/terminal.test.ts | 2 +-
packages/app/src/context/terminal.tsx | 120 +-
packages/app/src/index.ts | 1 -
packages/app/src/pages/layout.tsx | 48 +-
.../app/src/pages/layout/sidebar-project.tsx | 13 +-
.../app/src/pages/layout/sidebar-shell.tsx | 8 +-
.../src/pages/layout/sidebar-workspace.tsx | 31 +-
packages/app/src/utils/scoped-cache.test.ts | 5 +-
packages/app/src/utils/scoped-cache.ts | 17 +-
packages/app/src/utils/server-health.ts | 5 +-
.../desktop-electron/electron.vite.config.ts | 7 -
.../desktop-electron/src/main/constants.ts | 1 -
packages/desktop-electron/src/main/index.ts | 136 +--
packages/desktop-electron/src/main/migrate.ts | 3 +-
packages/desktop-electron/src/main/server.ts | 11 +-
packages/desktop-electron/src/main/store.ts | 10 +-
packages/desktop-electron/src/main/windows.ts | 4 +-
.../desktop-electron/src/main/wsl-servers.ts | 21 +-
.../desktop-electron/src/renderer/index.tsx | 92 +-
packages/desktop/src-tauri/src/server.rs | 35 +-
packages/desktop/src/index.tsx | 33 +-
packages/opencode/script/build.ts | 1 +
packages/opencode/src/cli/cmd/agent.ts | 300 +++--
packages/opencode/src/cli/cmd/github.ts | 369 +++---
packages/opencode/src/cli/cmd/mcp.ts | 1032 ++++++++---------
packages/opencode/src/cli/cmd/providers.ts | 464 ++++----
packages/opencode/src/cli/cmd/stats.ts | 232 ++--
packages/opencode/src/cli/effect-cmd.ts | 27 +-
.../instance/httpapi/handlers/session.ts | 6 +-
packages/opencode/src/v2/session.ts | 19 +-
.../test/cli/effect-cmd-instance-als.test.ts | 48 +
.../test/server/httpapi-parity.test.ts | 128 ++
packages/ui/src/components/dialog.css | 5 +-
packages/ui/src/context/dialog.tsx | 45 +-
48 files changed, 1766 insertions(+), 2107 deletions(-)
create mode 100644 packages/opencode/test/cli/effect-cmd-instance-als.test.ts
create mode 100644 packages/opencode/test/server/httpapi-parity.test.ts
diff --git a/bun.lock b/bun.lock
index 12677ea976..25068f3d9a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -715,7 +715,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
- "effect": "4.0.0-beta.57",
+ "effect": "4.0.0-beta.59",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -3078,7 +3078,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "effect": ["effect@4.0.0-beta.57", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-rg32VgXnLKaPRs9tbRDaZ5jxmzNY7ojXt85gSHGUTwdlbWH5Ik+OCUY2q14TXliygPGoHwCAvNWS4bQJOqf00g=="],
+ "effect": ["effect@4.0.0-beta.59", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-xyUDLeHSe8d6lWGOvR6Fgn2HL6gYeTZ/S4Jzk9uc4ZUxMPPsNZlNXrvk0C7/utQFzeX7uAWcVnG2BjbA0SRoAA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index bea97a0cb3..84c3b13043 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
- "aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
- "aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
- "x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
+ "x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
+ "aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
+ "aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
+ "x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
}
}
diff --git a/package.json b/package.json
index b15fbb2544..de3dd31f40 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
- "effect": "4.0.0-beta.57",
+ "effect": "4.0.0-beta.59",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 4f17538612..2649260cf3 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,8 +1,6 @@
import "@/index.css"
-import { Button } from "@opencode-ai/ui/button"
import * as Sentry from "@sentry/solid"
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"
@@ -13,9 +11,12 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
+import { Effect } from "effect"
import {
type Component,
createMemo,
+ createResource,
+ createSignal,
ErrorBoundary,
For,
type JSX,
@@ -23,7 +24,6 @@ import {
onCleanup,
type ParentProps,
Show,
- startTransition,
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -38,7 +38,6 @@ 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"
@@ -47,6 +46,7 @@ import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
+import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
@@ -75,6 +75,7 @@ declare global {
__OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
+ wsl?: boolean
activeServer?: string
}
api?: {
@@ -175,48 +176,80 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
const server = useServer()
- const healthy = createMemo(() => props.disableHealthCheck || server.healthy())
+ const checkServerHealth = useCheckServerHealth()
- const splash = (
-
-
-
+ const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
+
+ // performs repeated health check with a grace period for
+ // non-http connections, otherwise fails instantly
+ const [startupHealthCheck, healthCheckActions] = createResource(() =>
+ props.disableHealthCheck
+ ? true
+ : Effect.gen(function* () {
+ if (!server.current) return true
+ const { http, type } = server.current
+
+ while (true) {
+ const res = yield* Effect.promise(() => checkServerHealth(http))
+ if (res.healthy) return true
+ if (checkMode() === "background" || type === "http") return false
+ }
+ }).pipe(
+ Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
+ Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
+ Effect.runPromise,
+ ),
)
return (
-
-
-
- {
- startTransition(() => {
- server.setActive(key)
- })
- }}
- />
- }
- >
- {props.children}
-
-
-
-
+
+
+
+ }
+ >
+ {/*
+
+
+ }
+ >*/}
+ {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest}
+ {
+ if (checkMode() === "background") void healthCheckActions.refetch()
+ }}
+ onServerSelected={(key) => {
+ setCheckMode("blocking")
+ server.setActive(key)
+ void healthCheckActions.refetch()
+ }}
+ />
+ }
+ >
+ {props.children}
+
+ {/**/}
+
)
}
-function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key) => void }) {
- const dialog = useDialog()
+function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
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))
return (
@@ -228,34 +261,6 @@ function ConnectionError(props: { onServerSelected?: (key: ServerConnection.Key)
{unreachable()[1]}
{language.t("app.server.retrying")}
-
-
-
0}>
@@ -295,21 +300,13 @@ export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array
- serversReady?: boolean
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 ac0710c339..909d45ae71 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -8,7 +8,7 @@ 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 { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js"
+import { batch, createEffect, createMemo, createResource, onCleanup, Show, startTransition, untrack } 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"
@@ -75,6 +75,32 @@ function isWslSidecar(conn: ServerConnection.Any): conn is ServerConnection.Side
return conn.type === "sidecar" && conn.variant === "wsl"
}
+function useDefaultServer() {
+ const language = useLanguage()
+ const platform = usePlatform()
+ const [defaultKey, defaultActions] = createResource(
+ async () => {
+ try {
+ return (await platform.getDefaultServer?.()) ?? null
+ } catch (err) {
+ showRequestError(language, err)
+ return null
+ }
+ },
+ { initialValue: null },
+ )
+ const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
+ const setDefault = async (key: ServerConnection.Key | null) => {
+ try {
+ await platform.setDefaultServer?.(key)
+ defaultActions.mutate(key)
+ } catch (err) {
+ showRequestError(language, err)
+ }
+ }
+ return { defaultKey, canDefault, setDefault }
+}
+
function ServerForm(props: ServerFormProps) {
const language = useLanguage()
const keyDown = (event: KeyboardEvent) => {
@@ -146,6 +172,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const platform = usePlatform()
const language = useLanguage()
const wslServers = useWslServers()
+ const defaultServer = useDefaultServer()
const checkServerHealth = useCheckServerHealth()
let disposed = false
onCleanup(() => {
@@ -271,7 +298,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
},
onSuccess: async (key) => {
server.remove(key)
- if (server.defaultKey() === key) await server.setDefault(null)
+ if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
},
onError: (err) => showRequestError(language, err),
}))
@@ -546,7 +573,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
async function handleRemove(key: ServerConnection.Key) {
server.remove(key)
- if (server.defaultKey() === key) await server.setDefault(null)
+ if (defaultServer.defaultKey() === key) await defaultServer.setDefault(null)
}
return (
@@ -603,7 +630,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => health(key)?.healthy === false
- const canChangeDefault = () => server.canDefault() && i.type !== "ssh"
+ const canChangeDefault = () => defaultServer.canDefault() && i.type !== "ssh"
const canRemove = () => i.type === "http" || wsl
const outdated = () => {
const check = wslCheck(i)
@@ -632,7 +659,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
version={wslCheck(i)?.version ?? undefined}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
-
+
{language.t("dialog.server.status.default")}
@@ -689,15 +716,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
Retry start
-
- void server.setDefault(key)}>
+
+ void defaultServer.setDefault(key)}>
{language.t("dialog.server.menu.default")}
-
- void server.setDefault(null)}>
+
+ void defaultServer.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index a85e160d10..0a18096164 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1253,11 +1253,7 @@ export const PromptInput: Component = (props) => {
}
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
- queries: [
- loadAgentsQuery(sdk.directory),
- loadProvidersQuery(null),
- loadProvidersQuery(sdk.directory),
- ],
+ queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
}))
const agentsLoading = () => agentsQuery.isLoading
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 5442d9985d..3d4f58deec 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -6,10 +6,9 @@ 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/core/util/path"
-import { createEffect, createMemo, For, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -25,6 +24,7 @@ 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,13 +129,6 @@ 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()
@@ -226,7 +219,6 @@ 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(
@@ -240,19 +232,6 @@ 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)
@@ -290,8 +269,12 @@ export function SessionHeader() {
.catch((err: unknown) => showRequestError(language, err))
}
- const centerMount = createMemo(() => mounts.center)
- const rightMount = createMemo(() => mounts.right)
+ const [centerMount, setCenterMount] = createSignal(null)
+ const [rightMount, setRightMount] = createSignal(null)
+ onMount(() => {
+ setCenterMount(document.getElementById("opencode-titlebar-center"))
+ setRightMount(document.getElementById("opencode-titlebar-right"))
+ })
return (
<>
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index fcc42f04f2..952e3eac64 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -6,15 +6,16 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
-import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition } from "solid-js"
+import { type Accessor, 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 { loadMcpQuery } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
-import { ServerConnection, useServer } from "@/context/server"
+import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
+import { loadMcpQuery } from "@/context/global-sync"
const pollMs = 10_000
@@ -36,7 +37,7 @@ const listServersByHealth = (
status: Record,
) => {
if (!list.length) return list
- const order = new Map(list.map((conn, index) => [conn, index] as const))
+ const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
@@ -65,7 +66,7 @@ const useServerHealth = (servers: Accessor, enabled: Acc
let dead = false
const refresh = async () => {
- const results: Record = {}
+ const results: Record = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
@@ -86,6 +87,53 @@ const useServerHealth = (servers: Accessor, enabled: Acc
return status
}
+const useDefaultServerKey = (
+ get: (() => string | Promise | null | undefined) | undefined,
+) => {
+ const [state, setState] = createStore({
+ url: undefined as string | undefined,
+ tick: 0,
+ })
+
+ createEffect(() => {
+ state.tick
+ let dead = false
+ const result = get?.()
+ if (!result) {
+ setState("url", undefined)
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ if (result instanceof Promise) {
+ void result.then((next) => {
+ if (dead) return
+ setState("url", next ? normalizeServerUrl(next) : undefined)
+ })
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ setState("url", normalizeServerUrl(result))
+ onCleanup(() => {
+ dead = true
+ })
+ })
+
+ return {
+ key: () => {
+ const u = state.url
+ if (!u) return
+ return ServerConnection.key({ type: "http", http: { url: u } })
+ },
+ refresh: () => setState("tick", (value) => value + 1),
+ }
+}
+
const useMcpToggleMutation = () => {
const sync = useSync()
const sdk = useSDK()
@@ -111,10 +159,23 @@ const useMcpToggleMutation = () => {
export function StatusPopoverBody(props: { shown: Accessor }) {
const sync = useSync()
const server = useServer()
+ const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
+ const fail = (err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ }
+
+ createEffect(() => {
+ if (!props.shown()) return
+ })
+
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
@@ -131,6 +192,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
+ const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -189,18 +251,8 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
- startTransition(() => {
- batch(() => {
- if (server.key !== key) {
- if (typeof window !== "undefined" && window.history?.replaceState) {
- window.history.replaceState(null, "", "/")
- }
- } else {
- navigate("/")
- }
- server.setActive(key)
- })
- })
+ navigate("/")
+ queueMicrotask(() => server.setActive(key))
}}
>
@@ -212,7 +264,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
-
+
{language.t("common.default")}
@@ -236,7 +288,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
- dialog.show(() => navigate("/")} />)
+ dialog.show(() => , defaultServer.refresh)
})
}}
>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 12e1ba5e3f..ff5ff9dada 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -62,26 +62,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
},
}
-const getTerminalColors = (theme: ReturnType): TerminalColors => {
- const mode = theme.mode() === "dark" ? "dark" : "light"
- const fallback = DEFAULT_TERMINAL_COLORS[mode]
- const currentTheme = theme.themes()[theme.themeId()]
- if (!currentTheme) return fallback
- const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
- if (!variant?.seeds && !variant?.palette) return fallback
- const resolved = resolveThemeVariant(variant, mode === "dark")
- const text = resolved["text-stronger"] ?? fallback.foreground
- const background = resolved["background-stronger"] ?? fallback.background
- const alpha = mode === "dark" ? 0.25 : 0.2
- const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
- return {
- background,
- foreground: text,
- cursor: text,
- selectionBackground: withAlpha(base, alpha),
- }
-}
-
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
@@ -94,11 +74,6 @@ 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
@@ -258,7 +233,28 @@ export const Terminal = (props: TerminalProps) => {
})
}
- const terminalColors = createMemo(() => getTerminalColors(theme))
+ const getTerminalColors = (): TerminalColors => {
+ const mode = theme.mode() === "dark" ? "dark" : "light"
+ const fallback = DEFAULT_TERMINAL_COLORS[mode]
+ const currentTheme = theme.themes()[theme.themeId()]
+ if (!currentTheme) return fallback
+ const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
+ if (!variant?.seeds && !variant?.palette) return fallback
+ const resolved = resolveThemeVariant(variant, mode === "dark")
+ const text = resolved["text-stronger"] ?? fallback.foreground
+ const background = resolved["background-stronger"] ?? fallback.background
+ const alpha = mode === "dark" ? 0.25 : 0.2
+ const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
+ const selectionBackground = withAlpha(base, alpha)
+ return {
+ background,
+ foreground: text,
+ cursor: text,
+ selectionBackground,
+ }
+ }
+
+ const terminalColors = createMemo(getTerminalColors)
const scheduleFit = () => {
if (disposed) return
@@ -454,32 +450,20 @@ export const Terminal = (props: TerminalProps) => {
output.flush(resolve)
})
- // 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,
- })
+ if (restore && restoreSize) {
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 }
@@ -536,16 +520,6 @@ export const Terminal = (props: TerminalProps) => {
next.password = password
}
- logTerminal("socket.open", {
- id,
- serverKey: server.key ?? null,
- directory,
- restoreLength: restore.length,
- sdkUrl: sdk.url,
- currentUrl: url,
- wsUrl: next.toString(),
- })
-
const socket = new WebSocket(next)
socket.binaryType = "arraybuffer"
ws = socket
@@ -553,16 +527,6 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
if (disposed) return
tries = 0
- logTerminal("socket.connected", {
- id,
- serverKey: server.key ?? null,
- directory,
- currentUrl: url,
- })
- // 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)
}
@@ -617,14 +581,6 @@ 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: url,
- })
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
}
@@ -650,13 +606,6 @@ 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)
@@ -664,30 +613,17 @@ 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) {
- schedule()
+ finalize()
return
}
- output.flush(schedule)
+ output.flush(finalize)
})
return (
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index 5666442a4d..e53d60d5a0 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -95,15 +95,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
buffer.length = 0
}
- const clearPending = () => {
- if (timer) clearTimeout(timer)
- timer = undefined
- queue.length = 0
- buffer.length = 0
- coalesced.clear()
- staleDeltas.clear()
- }
-
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
@@ -211,10 +202,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
})().finally(() => {
run = undefined
- if (abort.signal.aborted || !started) {
- clearPending()
- return
- }
flush()
})
return run
@@ -238,7 +225,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
onCleanup(() => {
stop()
abort.abort()
- clearPending()
+ flush()
})
const sdk = createSdkForServer({
@@ -248,9 +235,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})
return {
- get url() {
- return server.current?.http.url ?? currentServer.http.url
- },
+ url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index db0c538104..0138310cdc 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -112,15 +112,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
const dispose = disposers.get(key)
if (dispose) {
+ dispose()
disposers.delete(key)
- // 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[key]
input.onDispose(key)
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index 928ed5ee2e..757c8e49a5 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -147,6 +147,12 @@ 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
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index 5a33529b4e..dffb798310 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -232,13 +232,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map()
const disposeAll = () => {
- // 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)
+ for (const entry of cache.values()) {
+ 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 2bc8951837..636c566a0a 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -1,11 +1,8 @@
-import { showToast } from "@opencode-ai/ui/toast"
import { createSimpleContext } from "@opencode-ai/ui/context"
-import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js"
+import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
-import { useLanguage } from "./language"
-import { usePlatform } from "./platform"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -26,7 +23,7 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
- if (key === "sidecar" || key === "local:windows") return "local"
+ if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
@@ -84,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("local:windows")
+ return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
@@ -100,13 +97,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
- serversReady?: boolean
servers?: Array
}) => {
const checkServerHealth = useCheckServerHealth()
- const language = useLanguage()
- const platform = usePlatform()
- const serversReady = () => props.serversReady ?? true
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
@@ -144,7 +137,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
- default: props.defaultServer as ServerConnection.Key | null,
healthy: undefined as boolean | undefined,
})
@@ -179,28 +171,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
- async function setDefault(input: ServerConnection.Key | null) {
- if (!platform.setDefaultServer) return input
- try {
- await platform.setDefaultServer(input)
- setState("default", input)
- return input
- } catch (err) {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- throw err
- }
- }
-
- 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
- }
+ createEffect(() => {
+ if (typeof window === "undefined") return
+ window.__OPENCODE__ ??= {}
+ window.__OPENCODE__.activeServer = state.active
+ })
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
@@ -223,39 +198,18 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
batch(() => {
setStore("list", list)
if (state.active === key) {
- setState("active", nextActiveKey(key))
+ const next = list[0]
+ setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
}
})
}
+ const isReady = createMemo(() => ready() && !!state.active)
+
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
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(() => {
- const list = allServers()
- const active = list.find((s) => ServerConnection.key(s) === state.active)
- if (active) return active
- if (!serversReady()) return
- return list[0]
- })
- const healthTarget = createMemo(() => {
- const conn = current()
- if (!conn) return ""
- return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n")
- })
- const isReady = createMemo(() => ready() && !!current())
-
- createEffect(() => {
- healthTarget()
- const current_ = untrack(current)
+ const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
@@ -266,14 +220,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
onCleanup(startHealthPolling(current_))
})
- createEffect(() => {
- if (!serversReady()) return
- const list = allServers()
- if (!list.length) return
- if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
- setState("active", nextActiveKey(state.active))
- })
-
+ const origin = createMemo(() => projectsKey(state.active))
+ const projectsList = createMemo(() => store.projects[origin()] ?? [])
+ const current: Accessor = createMemo(
+ () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
+ )
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
@@ -295,13 +246,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
get current() {
return current()
},
- canDefault() {
- return !!platform.getDefaultServer && !!platform.setDefaultServer
- },
- defaultKey() {
- return state.default
- },
- setDefault,
setActive,
add,
remove,
diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts
index 179af4f13c..6e07e03124 100644
--- a/packages/app/src/context/terminal.test.ts
+++ b/packages/app/src/context/terminal.test.ts
@@ -22,7 +22,7 @@ beforeAll(async () => {
})
describe("getWorkspaceTerminalCacheKey", () => {
- test("uses the workspace cache key", () => {
+ test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 1bb79ee054..31d2d6e04c 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -3,7 +3,6 @@ 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"
@@ -22,11 +21,6 @@ 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)
}
@@ -117,11 +111,10 @@ const trimTerminal = (pty: LocalPTY) => {
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
+ const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
- for (const [key, entry] of cache.entries()) {
- if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue
- entry.value.clear()
- }
+ const entry = cache.get(key)
+ entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, "terminal"), platform)
@@ -137,25 +130,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
-function createWorkspaceTerminalSession(
- sdk: ReturnType,
- dir: string,
- serverKey: string,
- legacySessionID?: string,
-) {
+function createWorkspaceTerminalSession(sdk: ReturnType, dir: 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(
- target,
+ {
+ ...Persist.workspace(dir, "terminal", legacy),
+ migrate: migrateTerminalState,
+ },
createStore<{
active?: string
all: LocalPTY[]
@@ -164,14 +146,6 @@ function createWorkspaceTerminalSession(
}),
)
- debugTerminal("session.create", {
- dir,
- serverKey,
- storage: target.storage,
- key: target.key,
- legacySessionID: legacySessionID ?? null,
- })
-
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
@@ -212,16 +186,6 @@ function createWorkspaceTerminalSession(
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) {
@@ -238,18 +202,11 @@ function createWorkspaceTerminalSession(
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
- 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,
- })}`,
- )
+ console.error("Failed to update terminal", 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
@@ -263,14 +220,6 @@ function createWorkspaceTerminalSession(
})
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(() => {
@@ -303,19 +252,11 @@ function createWorkspaceTerminalSession(
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),
@@ -348,12 +289,6 @@ function createWorkspaceTerminalSession(
},
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)
@@ -422,7 +357,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
- const server = useServer()
const params = useParams()
const cache = new Map()
@@ -430,9 +364,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
- const pending = Array.from(cache.values(), (entry) => entry.dispose)
+ for (const entry of cache.values()) {
+ entry.dispose()
+ }
cache.clear()
- for (const dispose of pending) dispose()
}
onCleanup(disposeAll)
@@ -447,30 +382,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
- const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => {
+ 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 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, serverKey, legacySessionID),
+ value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose,
}))
@@ -479,21 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
- const workspace = createMemo(() => {
- const key = server.key
- if (!key) return loadWorkspace(params.dir!, "", params.id)
- return loadWorkspace(params.dir!, key, params.id)
- })
+ const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
- const prevKey = server.key
- if (!prev?.dir || !prevKey) return
+ if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
- loadWorkspace(prev.dir, prevKey, prev.id).trimAll()
+ loadWorkspace(prev.dir, prev.id).trimAll()
},
{ defer: true },
),
@@ -508,7 +426,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(),
+ bind: () => workspace(),
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/index.ts b/packages/app/src/index.ts
index 2b5feecd74..e85ea84a5b 100644
--- a/packages/app/src/index.ts
+++ b/packages/app/src/index.ts
@@ -1,5 +1,4 @@
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"
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 3d3ec9bf05..b71be13dab 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -83,16 +83,9 @@ import {
LocalWorkspace,
SortableWorkspace,
WorkspaceDragOverlay,
- workspaceSortableDirectory,
- workspaceSortableId,
type WorkspaceSidebarContext,
} from "./layout/sidebar-workspace"
-import {
- ProjectDragOverlay,
- SortableProject,
- projectSortableWorktree,
- type ProjectSidebarContext,
-} from "./layout/sidebar-project"
+import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
export default function Layout(props: ParentProps) {
@@ -1844,7 +1837,7 @@ export default function Layout(props: ParentProps) {
)
function handleDragStart(event: unknown) {
- const id = projectSortableWorktree(getDraggableId(event))
+ const id = getDraggableId(event)
if (!id) return
setHoverProject(undefined)
setStore("activeProject", id)
@@ -1853,14 +1846,11 @@ 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 === from)
- const toIndex = projects.findIndex((p) => p.worktree === to)
+ const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
+ const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== -1) {
- layout.projects.move(from, toIndex)
+ layout.projects.move(draggable.id.toString(), toIndex)
}
}
}
@@ -1896,7 +1886,7 @@ export default function Layout(props: ParentProps) {
})
function handleWorkspaceDragStart(event: unknown) {
- const id = workspaceSortableDirectory(getDraggableId(event))
+ const id = getDraggableId(event)
if (!id) return
setStore("activeWorkspace", id)
}
@@ -1904,16 +1894,13 @@ 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 === from)
- const toIndex = ids.findIndex((dir) => dir === to)
+ const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
+ const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
if (fromIndex === -1 || toIndex === -1) return
if (fromIndex === toIndex) return
@@ -2274,13 +2261,13 @@ 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) => (
@@ -2337,7 +2324,6 @@ export default function Layout(props: ParentProps) {
}
const projects = () => 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}
- projectIds={projectIds}
- renderProject={(worktree) => {
- const project = createMemo(() => projects().find((item) => item.worktree === worktree))
- return (
-
- {(project) => (
-
- )}
-
- )
- }}
+ renderProject={(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 a4836b9efd..2ba20092c5 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -34,17 +34,6 @@ 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
@@ -286,7 +275,7 @@ export const SortableProject = (props: {
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
- const sortable = createSortable(projectSortableId(props.project.worktree))
+ const sortable = createSortable(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 d9cd4d5a20..ca36af2a42 100644
--- a/packages/app/src/pages/layout/sidebar-shell.tsx
+++ b/packages/app/src/pages/layout/sidebar-shell.tsx
@@ -11,15 +11,13 @@ 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
- projectIds: Accessor
- renderProject: (worktree: string) => JSX.Element
+ renderProject: (project: LocalProject) => JSX.Element
handleDragStart: (event: unknown) => void
handleDragEnd: () => void
handleDragOver: (event: DragEvent) => void
@@ -65,8 +63,8 @@ export const SidebarContent = (props: {