feedback n stuff?

This commit is contained in:
LukeParkerDev
2026-04-19 13:21:48 +10:00
parent e3d2a9ddbb
commit bc84698428
24 changed files with 5660 additions and 143 deletions
@@ -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<ServerConnection.Key, ServerHealth>()
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<ServerConnection.Key, ServerHealth | undefined>,
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 (
<Dialog title={formTitle()} dismissOutside={!isAddWslMode()}>
<div class="flex flex-col gap-2">
@@ -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 (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
<ServerHealthIndicator health={health(key)} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
dimmed={health(key)?.healthy === false}
status={health(key)}
version={displayVersion(i)}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
@@ -621,6 +731,23 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
showCredentials
/>
<div class="flex items-center justify-center gap-3 pl-4">
<Show when={isWslSidecar && opencodeAction()}>
{(label) => (
<Button
variant="secondary"
size="small"
disabled={!!store.wslState?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
onClick={(e: MouseEvent) => {
e.stopPropagation()
void handleUpdateWsl(i)
}}
>
{updating() ? "Updating OpenCode..." : label()}
</Button>
)}
</Show>
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
@@ -647,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={isWslSidecar && store.status[key]?.healthy === false}>
<Show when={isWslSidecar && health(key)?.healthy === false}>
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
@@ -666,8 +793,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={i.type === "http" || isWslSidecar}>
<Show when={hasMenuActionsBeforeDelete()}>
<DropdownMenu.Separator />
</Show>
<Show when={i.type === "http" || isWslSidecar}>
<DropdownMenu.Item
onSelect={() => (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
@@ -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 (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!store.loading} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<div class="flex gap-2 overflow-x-auto pb-1">
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="min-w-[132px] rounded-md border px-3 py-2 text-left transition-colors"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
@@ -343,7 +369,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{current()?.installed.length
{visibleInstalledDistros().length
? "All installed distros are already added."
: current()?.runtime?.available
? "No distros detected yet."
@@ -375,14 +401,24 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
>
Install
</Button>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro() && installDistroPercent() !== null}>
<span class="text-12-regular text-text-weak shrink-0 tabular-nums min-w-[3ch] text-right">
{installDistroPercent()}%
</span>
</Show>
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
</div>
</div>
<div
role="radiogroup"
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any
status?: ServerHealth
version?: string
class?: string
nameClass?: string
versionClass?: string
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => 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 = () => (
<span class="flex items-center gap-2">
<span>{serverName(props.conn, true)}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-weak">v{props.status?.version}</span>
<Show when={isWsl()}>
<span class="text-text-invert-weak">WSL</span>
</Show>
<Show when={version()}>
<span class="text-text-invert-weak">v{version()}</span>
</Show>
</span>
)
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
{name()}
</span>
<Show when={isWsl()}>
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
WSL
</span>
</Show>
<Show
when={badge()}
fallback={
<Show when={props.status?.version}>
<Show when={version()}>
<span
ref={versionRef}
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
>
v{props.status?.version}
v{version()}
</span>
</Show>
}
@@ -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<string | null | undefined> | 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<boolean> }) {
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 {
+98 -16
View File
@@ -74,6 +74,11 @@ const errorName = (err: unknown) => {
return typeof errorName === "string" ? errorName : undefined
}
const logTerminal = (phase: string, input: Record<string, unknown>) => {
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)
+3 -1
View File
@@ -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),
+16 -2
View File
@@ -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<ServerConnection.Any | undefined> = 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))
+4 -3
View File
@@ -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__")
})
})
+137 -21
View File
@@ -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<string, unknown>) => {
if (!import.meta.env.DEV) return
console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`)
}
function record(value: unknown): value is Record<string, unknown> {
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<typeof useSDK>, dir: string, legacySessionID?: string) {
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
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<typeof useSDK>, 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<typeof useSDK>, dir: str
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { 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<typeof useSDK>, 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<typeof useSDK>["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<typeof useSDK>, 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<typeof useSDK>, 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<typeof useSDK>, 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<string, TerminalCacheEntry>()
@@ -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<LocalPTY> & { id: string }) {},
trim(_id: string) {},
trimAll() {},
clone: async (_id: string) => {},
bind() {
return {
trim(_id: string) {},
update(_pty: Partial<LocalPTY> & { id: string }) {},
clone: async (_id: string) => {},
}
},
open(_id: string) {},
close: async (_id: string) => {},
move(_id: string, _to: number) {},
next() {},
previous() {},
} as unknown as ReturnType<typeof createWorkspaceTerminalSession>
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),
+31 -1
View File
@@ -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?.())
}