From 85dc2f4e7a06d5faca39ad9ea9825693f98eb21d Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 16 Feb 2026 15:28:11 +0800 Subject: [PATCH 01/11] start refactoring server management to store more than just url --- .zed/settings.json | 9 ++ packages/app/src/app.tsx | 96 +++++-------- .../src/components/dialog-select-server.tsx | 113 +++++++-------- .../app/src/components/prompt-input/submit.ts | 29 ++-- .../app/src/components/status-popover.tsx | 28 ++-- packages/app/src/components/terminal.tsx | 50 ++++--- packages/app/src/context/global-sdk.tsx | 35 ++--- packages/app/src/context/global-sync.tsx | 68 +++++---- .../app/src/context/global-sync/bootstrap.ts | 30 ++-- packages/app/src/context/sdk.tsx | 13 +- packages/app/src/context/server.tsx | 131 +++++++++++++----- packages/app/src/entry.tsx | 15 +- packages/app/src/index.ts | 3 +- packages/app/src/utils/server-health.test.ts | 21 ++- packages/app/src/utils/server-health.ts | 14 +- packages/app/src/utils/server.ts | 22 +++ packages/desktop/src/index.tsx | 80 +++++------ 17 files changed, 445 insertions(+), 312 deletions(-) create mode 100644 .zed/settings.json create mode 100644 packages/app/src/utils/server.ts diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 000000000000..a3a5e1e2b219 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,9 @@ +{ + "format_on_save": "on", + "formatter": { + "external": { + "command": "bunx", + "arguments": ["prettier", "--stdin-filepath", "{buffer_path}"] + } + } +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 1121c2e955ac..bcb4f044cff8 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,35 +1,36 @@ import "@/index.css" -import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js" -import { Router, Route, Navigate } from "@solidjs/router" -import { MetaProvider } from "@solidjs/meta" -import { Font } from "@opencode-ai/ui/font" -import { MarkedProvider } from "@opencode-ai/ui/context/marked" -import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" -import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { Code } from "@opencode-ai/ui/code" import { I18nProvider } from "@opencode-ai/ui/context" +import { CodeComponentProvider } from "@opencode-ai/ui/context/code" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { Diff } from "@opencode-ai/ui/diff" -import { Code } from "@opencode-ai/ui/code" +import { Font } from "@opencode-ai/ui/font" import { ThemeProvider } from "@opencode-ai/ui/theme" +import { MetaProvider } from "@solidjs/meta" +import { Navigate, Route, Router } from "@solidjs/router" +import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" +import { CommandProvider } from "@/context/command" +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" -import { PermissionProvider } from "@/context/permission" +import { HighlightsProvider } from "@/context/highlights" +import { LanguageProvider, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" -import { GlobalSDKProvider } from "@/context/global-sdk" -import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" -import { SettingsProvider } from "@/context/settings" -import { TerminalProvider } from "@/context/terminal" -import { PromptProvider } from "@/context/prompt" -import { FileProvider } from "@/context/file" -import { CommentsProvider } from "@/context/comments" -import { NotificationProvider } from "@/context/notification" import { ModelsProvider } from "@/context/models" -import { DialogProvider } from "@opencode-ai/ui/context/dialog" -import { CommandProvider } from "@/context/command" -import { LanguageProvider, useLanguage } from "@/context/language" +import { NotificationProvider } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { HighlightsProvider } from "@/context/highlights" -import Layout from "@/pages/layout" +import { PromptProvider } from "@/context/prompt" +import { normalizeServerUrl, type ServerConnection, ServerProvider, useServer } from "@/context/server" +import { SettingsProvider } from "@/context/settings" +import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" +import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" + const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
@@ -57,7 +58,12 @@ function UiI18nBridge(props: ParentProps) { declare global { interface Window { - __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } + __OPENCODE__?: { + updaterEnabled?: boolean + serverPassword?: string + deepLinks?: string[] + wsl?: boolean + } } } @@ -107,30 +113,6 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -const getStoredDefaultServerUrl = (platform: ReturnType) => { - if (platform.platform !== "web") return - const result = platform.getDefaultServerUrl?.() - if (result instanceof Promise) return - if (!result) return - return normalizeServerUrl(result) -} - -const resolveDefaultServerUrl = (props: { - defaultUrl?: string - storedDefaultServerUrl?: string - hostname: string - origin: string - isDev: boolean - devHost?: string - devPort?: string -}) => { - if (props.defaultUrl) return props.defaultUrl - if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl - if (props.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}` - return props.origin -} - export function AppBaseProviders(props: ParentProps) { return ( @@ -163,21 +145,13 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { - const platform = usePlatform() - const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) - const defaultServerUrl = resolveDefaultServerUrl({ - defaultUrl: props.defaultUrl, - storedDefaultServerUrl, - hostname: location.hostname, - origin: window.location.origin, - isDev: import.meta.env.DEV, - devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST, - devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT, - }) - +export function AppInterface(props: { + children?: JSX.Element + defaultUrl: string + servers?: Array +}) { return ( - + diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4c37806365a2..0d15eba16763 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,19 +1,19 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, useServer } from "@/context/server" -import { usePlatform } from "@/context/platform" -import { useNavigate } from "@solidjs/router" -import { useLanguage } from "@/context/language" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" import { ServerRow } from "@/components/server/server-row" +import { useGlobalSDK } from "@/context/global-sdk" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { @@ -89,7 +89,7 @@ function useServerPreview(fetcher: typeof fetch) { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStatus(result.healthy) } @@ -214,24 +214,24 @@ export function DialogSelectServer() { }) } - const replaceServer = (original: string, next: string) => { + const replaceServer = (original: ServerConnection.Http, next: string) => { const active = server.url - const nextActive = active === original ? next : active + const nextActive = active === original.http.url ? next : active server.add(next) if (nextActive) server.setActive(nextActive) - server.remove(original) + server.remove(original.http.url) } const items = createMemo(() => { - const current = server.url + const current = server.current const list = server.list if (!current) return list if (!list.includes(current)) return [current, ...list] return [current, ...list.filter((x) => x !== current)] }) - const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + const current = createMemo(() => items().find((x) => x.http.url === server.url) ?? items()[0]) const sortedItems = createMemo(() => { const list = items() @@ -246,7 +246,7 @@ export function DialogSelectServer() { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[a]) - rank(store.status[b]) + const diff = rank(store.status[a.http.url]) - rank(store.status[b.http.url]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) @@ -255,8 +255,8 @@ export function DialogSelectServer() { async function refreshHealth() { const results: Record = {} await Promise.all( - items().map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) + items().map(async ({ http }) => { + results[http.url] = await checkServerHealth(http, fetcher) }), ) setStore("status", reconcile(results)) @@ -269,15 +269,15 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - async function select(value: string, persist?: boolean) { - if (!persist && store.status[value]?.healthy === false) return + async function select(value: ServerConnection.Any, persist?: boolean) { + if (!persist && store.status[value.http.url]?.healthy === false) return dialog.close() if (persist) { - server.add(value) + server.add(value.http.url) navigate("/") return } - server.setActive(value) + server.setActive(value.http.url) navigate("/") } @@ -311,7 +311,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -320,25 +320,25 @@ export function DialogSelectServer() { } resetAdd() - await select(normalized, true) + await select({ type: "http", http: { url: normalized } }, true) } - async function handleEdit(original: string, value: string) { - if (store.editServer.busy) return + async function handleEdit(original: ServerConnection.Any, value: string) { + if (store.editServer.busy || original.type !== "http") return const normalized = normalizeServerUrl(value) if (!normalized) { resetEdit() return } - if (normalized === original) { + if (normalized === original.http.url) { resetEdit() return } setStore("editServer", { busy: true, error: "" }) - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -366,7 +366,7 @@ export function DialogSelectServer() { handleAdd(store.addServer.url) } - const handleEditKey = (event: KeyboardEvent, original: string) => { + const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() @@ -390,11 +390,14 @@ export function DialogSelectServer() {
(listRoot = el)}> x} + key={(x) => x.http.url} onSelect={(x) => { if (x) select(x) }} @@ -428,7 +431,7 @@ export function DialogSelectServer() { return (
+ {language.t("dialog.server.status.default")} @@ -456,7 +459,7 @@ export function DialogSelectServer() { } /> - +

{language.t("dialog.server.current")}

@@ -473,26 +476,28 @@ export function DialogSelectServer() { /> - { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) - }} - > - {language.t("dialog.server.menu.edit")} - - - setDefault(i)}> + + { + setStore("editServer", { + id: i.http.url, + value: i.http.url, + error: "", + status: store.status[i.http.url]?.healthy, + }) + }} + > + {language.t("dialog.server.menu.edit")} + + + + setDefault(i.http.url)}> {language.t("dialog.server.menu.default")} - + setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} @@ -501,7 +506,7 @@ export function DialogSelectServer() { handleRemove(i)} + onSelect={() => handleRemove(i.http.url)} class="text-text-on-critical-base hover:bg-surface-critical-weak" > {language.t("dialog.server.menu.delete")} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9a1fba5d5c49..97f639eba20b 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,21 +1,21 @@ -import { Accessor } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" -import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" -import { useLocal } from "@/context/local" -import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" +import { useNavigate, useParams } from "@solidjs/router" +import type { Accessor } from "solid-js" +import type { FileSelection } from "@/context/file" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { usePlatform } from "@/context/platform" +import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" -import { useLanguage } from "@/context/language" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" -import type { FileSelection } from "@/context/file" -import { setCursorPosition } from "./editor-dom" import { buildRequestParts } from "./build-request-parts" +import { setCursorPosition } from "./editor-dom" type PendingPrompt = { abort: AbortController @@ -171,9 +171,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (sessionDirectory !== projectDirectory) { - client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: platform.fetch, + client = sdk.createClient({ directory: sessionDirectory, throwOnError: true, }) @@ -368,7 +366,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const timer = { id: undefined as number | undefined } const timeout = new Promise>>((resolve) => { timer.id = window.setTimeout(() => { - resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) + resolve({ + status: "failed", + message: language.t("workspace.error.stillPreparing"), + }) }, timeoutMs) }) diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 38152b82314c..1d1743b5267f 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,21 +1,21 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { useNavigate } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" import { Popover } from "@opencode-ai/ui/popover" -import { Tabs } from "@opencode-ai/ui/tabs" -import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" -import { Icon } from "@opencode-ai/ui/icon" +import { Tabs } from "@opencode-ai/ui/tabs" import { showToast } from "@opencode-ai/ui/toast" -import { useSync } from "@/context/sync" +import { useNavigate } from "@solidjs/router" +import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { ServerRow } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { normalizeServerUrl, useServer } from "@/context/server" -import { usePlatform } from "@/context/platform" -import { useLanguage } from "@/context/language" -import { DialogSelectServer } from "./dialog-select-server" -import { ServerRow } from "@/components/server/server-row" +import { useSync } from "@/context/sync" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" +import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -64,7 +64,7 @@ const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => const results: Record = {} await Promise.all( list.map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) + results[url] = await checkServerHealth({ url }, fetcher) }), ) if (dead) return @@ -166,8 +166,8 @@ export function StatusPopover() { const current = server.url const list = server.list if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((item) => item !== current)] + // if (!list.includes(current)) return [current, ...list] + return [current /* ...list.filter((item) => item !== current) */] }) const health = useServerHealth(servers, fetcher) const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 14413dfda677..6582fb85a08c 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,14 +1,15 @@ -import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { showToast } from "@opencode-ai/ui/toast" +import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" +import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { SerializeAddon } from "@/addons/serialize" +import { matchKeybind, parseKeybind } from "@/context/command" +import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" import { monoFontFamily, useSettings } from "@/context/settings" -import { parseKeybind, matchKeybind } from "@/context/command" -import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/terminal" -import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" -import { useLanguage } from "@/context/language" -import { showToast } from "@opencode-ai/ui/toast" +import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: { input.container.addEventListener("pointerdown", input.handlePointerDown) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) - input.container.addEventListener("click", input.handleLinkClick, { capture: true }) - input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) + input.container.addEventListener("click", input.handleLinkClick, { + capture: true, + }) + input.cleanups.push(() => + input.container.removeEventListener("click", input.handleLinkClick, { + capture: true, + }), + ) input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("blur", handleTextareaBlur) @@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() + const server = useServer() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) let ws: WebSocket | undefined @@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => { serializeAddon = serializer t.open(container) - useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) + useTerminalUiBindings({ + container, + term: t, + cleanups, + handlePointerDown, + handleLinkClick, + }) focusTerminal() @@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => { url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - if (window.__OPENCODE__?.serverPassword) { - url.username = "opencode" - url.password = window.__OPENCODE__?.serverPassword - } + url.username = server.http?.username ?? "" + url.password = server.http?.password ?? "" const socket = new WebSocket(url) socket.binaryType = "arraybuffer" ws = socket @@ -521,7 +533,13 @@ export const Terminal = (props: TerminalProps) => { if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) output?.flush() - persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) + persistTerminal({ + term, + addon: serializeAddon, + cursor, + pty: local.pty, + onCleanup: props.onCleanup, + }) cleanup() }) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 3f93b76a723c..9678605a62ce 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -1,7 +1,8 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" +import { createSdkForServer } from "@/utils/server" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -12,16 +13,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const platform = usePlatform() const abort = new AbortController() - const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword - - const auth = (() => { - if (!password) return - if (!server.isLocal()) return - return { - Authorization: `Basic ${btoa(`opencode:${password}`)}`, - } - })() - const eventFetch = (() => { if (!platform.fetch) return try { @@ -33,11 +24,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })() - const eventSdk = createOpencodeClient({ - baseUrl: server.url, + const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, - headers: eventFetch ? undefined : auth, + server: server.current.http, }) const emitter = createGlobalEmitter<{ [key: string]: Event @@ -150,12 +140,23 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo flush() }) - const sdk = createOpencodeClient({ - baseUrl: server.url, + const sdk = createSdkForServer({ + server: server.current.http, fetch: platform.fetch, throwOnError: true, }) - return { url: server.url, client: sdk, event: emitter } + return { + url: server.url, + client: sdk, + event: emitter, + createClient(opts: Omit[0], "server" | "fetch">) { + return createSdkForServer({ + server: server.current.http, + fetch: platform.fetch, + ...opts, + }) + }, + } }, }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 62c7eb66ec9c..1c4a3ee521fa 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -1,40 +1,40 @@ -import { - type Config, - type Path, - type Project, - type ProviderAuthResponse, - type ProviderListResponse, - createOpencodeClient, +import type { + Config, + OpencodeClient, + Path, + Project, + ProviderAuthResponse, + ProviderListResponse, } from "@opencode-ai/sdk/v2/client" -import { createStore, produce, reconcile } from "solid-js/store" -import { useGlobalSDK } from "./global-sdk" -import type { InitError } from "../pages/error" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" import { createContext, createEffect, - untrack, getOwner, - useContext, + Match, onCleanup, onMount, type ParentProps, Switch, - Match, + untrack, + useContext, } from "solid-js" -import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { usePlatform } from "./platform" +import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" -import { createRefreshQueue } from "./global-sync/queue" +import type { InitError } from "../pages/error" +import { useGlobalSDK } from "./global-sdk" +import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" -import { trimSessions } from "./global-sync/session-trim" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" -import { sanitizeProject } from "./global-sync/utils" +import { createRefreshQueue } from "./global-sync/queue" +import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" +import { sanitizeProject } from "./global-sync/utils" +import { usePlatform } from "./platform" type GlobalStore = { ready: boolean @@ -73,7 +73,7 @@ function createGlobalSync() { loadSessionsFallback: 0, } - const sdkCache = new Map>() + const sdkCache = new Map() const booting = new Map>() const sessionLoads = new Map>() const sessionMeta = new Map() @@ -132,9 +132,7 @@ function createGlobalSync() { const sdkFor = (directory: string) => { const cached = sdkCache.get(directory) if (cached) return cached - const sdk = createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, + const sdk = globalSDK.createClient({ directory, throwOnError: true, }) @@ -174,7 +172,10 @@ function createGlobalSync() { const [store, setStore] = children.child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) if (meta && meta.limit >= store.limit) { - const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) + const next = trimSessions(store.session, { + limit: store.limit, + permission: store.permission, + }) if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } @@ -199,10 +200,17 @@ function createGlobalSync() { .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) const limit = store.limit const childSessions = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission }) + const sessions = trimSessions([...nonArchived, ...childSessions], { + limit, + permission: store.permission, + }) setStore( "sessionTotal", - estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), ) setStore("session", reconcile(sessions, { key: "id" })) sessionMeta.set(directory, { limit }) @@ -306,7 +314,9 @@ function createGlobalSync() { await bootstrapGlobal({ globalSDK: globalSDK.client, connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }), + connectErrorDescription: language.t("error.globalSync.connectFailed", { + url: globalSDK.url, + }), requestFailedTitle: language.t("common.requestFailed"), setGlobalStore, }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 2137a19a823e..9a2f75586e15 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -1,20 +1,20 @@ -import { - type Config, - type Path, - type PermissionRequest, - type Project, - type ProviderAuthResponse, - type ProviderListResponse, - type QuestionRequest, - createOpencodeClient, +import type { + Config, + OpencodeClient, + Path, + PermissionRequest, + Project, + ProviderAuthResponse, + ProviderListResponse, + QuestionRequest, } from "@opencode-ai/sdk/v2/client" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" +import { retry } from "@opencode-ai/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" -import { retry } from "@opencode-ai/util/retry" -import { getFilename } from "@opencode-ai/util/path" -import { showToast } from "@opencode-ai/ui/toast" -import { cmp, normalizeProviderList } from "./utils" import type { State, VcsCache } from "./types" +import { cmp, normalizeProviderList } from "./utils" type GlobalStore = { ready: boolean @@ -27,7 +27,7 @@ type GlobalStore = { } export async function bootstrapGlobal(input: { - globalSDK: ReturnType + globalSDK: OpencodeClient connectErrorTitle: string connectErrorDescription: string requestFailedTitle: string @@ -106,7 +106,7 @@ function groupBySession(input: T[]) export async function bootstrapDirectory(input: { directory: string - sdk: ReturnType + sdk: OpencodeClient store: Store setStore: SetStoreFunction vcsCache: VcsCache diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx index 555933619af2..bc97ea13acc8 100644 --- a/packages/app/src/context/sdk.tsx +++ b/packages/app/src/context/sdk.tsx @@ -1,9 +1,8 @@ -import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { useGlobalSDK } from "./global-sdk" -import { usePlatform } from "./platform" type SDKEventMap = { [key in Event["type"]]: Extract @@ -12,14 +11,11 @@ type SDKEventMap = { export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { directory: Accessor }) => { - const platform = usePlatform() const globalSDK = useGlobalSDK() const directory = createMemo(props.directory) const client = createMemo(() => - createOpencodeClient({ - baseUrl: globalSDK.url, - fetch: platform.fetch, + globalSDK.createClient({ directory: directory(), throwOnError: true, }), @@ -45,6 +41,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ get url() { return globalSDK.url }, + createClient(opts: Parameters[0]) { + return globalSDK.createClient(opts) + }, } }, }) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 5d3d0cf3aa6c..67e9cacbf9ed 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -27,57 +27,118 @@ function projectsKey(url: string) { return url } +export namespace ServerConnection { + type Base = { displayName?: string } + + export type HttpBase = { + url: string + username?: string + password?: string + } + + // Regular web connections + export type Http = { + type: "http" + http: HttpBase + } & Base + + export type Sidecar = { + type: "local" + http: HttpBase + } & ( + | // Regular desktop server + { variant: "base" } + // WSL server (windows only) + | { + variant: "wsl" + distro: string + } + ) & + Base + + // Remote server desktop can SSH into + export type Ssh = { + type: "ssh" + host: string + // SSH client exposes an HTTP server for the app to use as a proxy + http: HttpBase + } & Base + + export type Any = + | Http + // All these are desktop-only + | (Sidecar | Ssh) + + export const key = (conn: Any): string => { + switch (conn.type) { + case "http": + return conn.http.url + case "local": { + if (conn.variant === "wsl") return `wsl:${conn.distro}` + return "local" + } + case "ssh": + return `ssh:${conn.host}` + } + } +} + export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", - init: (props: { defaultUrl: string; isSidecar?: boolean }) => { + init: (props: { defaultUrl: string; isSidecar?: boolean; servers?: Array }) => { const platform = usePlatform() const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), createStore({ list: [] as string[], - currentSidecarUrl: "", projects: {} as Record, lastProject: {} as Record, }), ) + const allServers = (): Array => [ + ...(props.servers ?? []), + ...store.list.map((url) => ({ + type: "http" as const, + http: { url }, + })), + ] + const [state, setState] = createStore({ - active: "", + active: props.defaultUrl, healthy: undefined as boolean | undefined, }) const healthy = () => state.healthy - const defaultUrl = () => normalizeServerUrl(props.defaultUrl) + // const defaultUrl = () => normalizeServerUrl(props.defaultUrl) function reconcileStartup() { - const fallback = defaultUrl() + const fallback = props.defaultUrl if (!fallback) return - - const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl) - const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list - if (!props.isSidecar) { - batch(() => { - setStore("list", list) - if (store.currentSidecarUrl) setStore("currentSidecarUrl", "") - setState("active", fallback) - }) - return - } - - const nextList = list.includes(fallback) ? list : [...list, fallback] - batch(() => { - setStore("list", nextList) - setStore("currentSidecarUrl", fallback) - setState("active", fallback) - }) + // const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl) + // const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list + // if (!props.isSidecar) { + // batch(() => { + // setStore("list", list) + // if (store.currentSidecarUrl) setStore("currentSidecarUrl", "") + setState("active", fallback) + // }) + // return + // } + // const nextList = list.includes(fallback) ? list : [...list, fallback] + // batch(() => { + // setStore("list", nextList) + // setStore("currentSidecarUrl", fallback) + // setState("active", fallback) + // }) } function updateServerList(url: string, remove = false) { if (remove) { const list = store.list.filter((x) => x !== url) - const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active + const next = state.active === url ? (list[0] ?? props.defaultUrl ?? "") : state.active batch(() => { setStore("list", list) setState("active", next) @@ -93,14 +154,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - function startHealthPolling(url: string) { + function startHealthPolling(conn: ServerConnection.Any) { let alive = true let busy = false const run = () => { if (busy) return busy = true - void check(url) + void check(conn) .then((next) => { if (!alive) return setState("healthy", next) @@ -145,19 +206,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const isReady = createMemo(() => ready() && !!state.active) const fetcher = platform.fetch ?? globalThis.fetch - const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) + const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) createEffect(() => { - const url = state.active - if (!url) return + const current_ = current() + if (!current_) return setState("healthy", undefined) - onCleanup(startHealthPolling(url)) + onCleanup(startHealthPolling(current_)) }) const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") + const current = createMemo(() => allServers().find((s) => s.http.url === state.active)) return { ready: isReady, @@ -170,7 +232,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( return serverDisplayName(state.active) }, get list() { - return store.list + return allServers() + }, + get current() { + return current()! + }, + get http() { + const c = current() + return c?.http! }, setActive, add, diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 3a85086b48b0..6531bcd171fe 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -1,7 +1,9 @@ // @refresh reload + +import { iife } from "@opencode-ai/util/iife" import { render } from "solid-js/web" import { AppBaseProviders, AppInterface } from "@/app" -import { Platform, PlatformProvider } from "@/context/platform" +import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" @@ -107,12 +109,21 @@ const platform: Platform = { setDefaultServerUrl: writeDefaultServerUrl, } +const defaultUrl = iife(() => { + const lsDefault = readDefaultServerUrl() + if (lsDefault) return lsDefault + if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + return location.origin +}) + if (root instanceof HTMLElement) { render( () => ( - + ), diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 33c22f099e35..6c870dfa4d02 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,4 +1,5 @@ -export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" export { useCommand } from "./context/command" +export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" +export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index 26bda070a67a..50082dcf35d0 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" +import type { ServerConnection } from "@/context/server" import { checkServerHealth } from "./server-health" +const server: ServerConnection.HttpBase = { + url: "http://localhost:4096", +} + function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { if (init?.signal) return init.signal if (input instanceof Request) return input.signal @@ -15,7 +20,7 @@ describe("checkServerHealth", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof globalThis.fetch - const result = await checkServerHealth("http://localhost:4096", fetch) + const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) }) @@ -25,7 +30,7 @@ describe("checkServerHealth", () => { throw new Error("network") }) as unknown as typeof globalThis.fetch - const result = await checkServerHealth("http://localhost:4096", fetch) + const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: false }) }) @@ -51,7 +56,9 @@ describe("checkServerHealth", () => { ) })) as unknown as typeof globalThis.fetch - const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { + const result = await checkServerHealth(server, fetch, { + timeoutMs: 10, + }).finally(() => { if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") }) @@ -71,7 +78,9 @@ describe("checkServerHealth", () => { }) as unknown as typeof globalThis.fetch const abort = new AbortController() - await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) + await checkServerHealth(server, fetch, { + signal: abort.signal, + }) expect(signal).toBe(abort.signal) }) @@ -87,7 +96,7 @@ describe("checkServerHealth", () => { }) }) as unknown as typeof globalThis.fetch - const result = await checkServerHealth("http://localhost:4096", fetch, { + const result = await checkServerHealth(server, fetch, { retryCount: 2, retryDelayMs: 1, }) @@ -103,7 +112,7 @@ describe("checkServerHealth", () => { throw new TypeError("network") }) as unknown as typeof globalThis.fetch - const result = await checkServerHealth("http://localhost:4096", fetch, { + const result = await checkServerHealth(server, fetch, { retryCount: 2, retryDelayMs: 1, }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 929826d0dea4..db4aa89bde10 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,4 +1,5 @@ -import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { ServerConnection } from "@/context/server" +import { createSdkForServer } from "./server" export type ServerHealth = { healthy: boolean; version?: string } @@ -17,7 +18,10 @@ function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout if (timeout) { try { - return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } + return { + signal: timeout.call(AbortSignal, timeoutMs), + clear: undefined as (() => void) | undefined, + } } catch {} } const controller = new AbortController() @@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) { } export async function checkServerHealth( - url: string, + server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, opts?: CheckServerHealthOptions, ): Promise { @@ -67,8 +71,8 @@ export async function checkServerHealth( .catch(() => ({ healthy: false })) } const attempt = (count: number): Promise => - createOpencodeClient({ - baseUrl: url, + createSdkForServer({ + server, fetch, signal, }) diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts new file mode 100644 index 000000000000..17f4a3adcece --- /dev/null +++ b/packages/app/src/utils/server.ts @@ -0,0 +1,22 @@ +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { ServerConnection } from "@/context/server" + +export function createSdkForServer({ + server, + ...config +}: Omit[0]>, "baseUrl"> & { + server: ServerConnection.HttpBase +}) { + const auth = (() => { + if (!server.password) return + return { + Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`, + } + })() + + return createOpencodeClient({ + ...config, + headers: { ...config.headers, ...auth }, + baseUrl: server.url, + }) +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index ff0a093766ed..5aebf96930ea 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,36 +1,38 @@ // @refresh reload -import { webviewZoom } from "./webview-zoom" -import { render } from "solid-js/web" + import { AppBaseProviders, AppInterface, + handleNotificationClick, + type Platform, PlatformProvider, - Platform, + type ServerConnection, useCommand, - handleNotificationClick, } from "@opencode-ai/app" -import { open, save } from "@tauri-apps/plugin-dialog" +import { Splash } from "@opencode-ai/ui/logo" +import type { AsyncStorage } from "@solid-primitives/storage" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" +import { open, save } from "@tauri-apps/plugin-dialog" +import { fetch as tauriFetch } from "@tauri-apps/plugin-http" +import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" -import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" -import { check, Update } from "@tauri-apps/plugin-updater" -import { getCurrentWindow } from "@tauri-apps/api/window" -import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" -import { AsyncStorage } from "@solid-primitives/storage" -import { fetch as tauriFetch } from "@tauri-apps/plugin-http" +import { open as shellOpen } from "@tauri-apps/plugin-shell" import { Store } from "@tauri-apps/plugin-store" -import { Splash } from "@opencode-ai/ui/logo" -import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" -import { readImage } from "@tauri-apps/plugin-clipboard-manager" - -import { UPDATER_ENABLED } from "./updater" -import { initI18n, t } from "./i18n" +import { check, type Update } from "@tauri-apps/plugin-updater" +import { type Accessor, createResource, createSignal, type JSX, onCleanup, onMount, Show } from "solid-js" +import { render } from "solid-js/web" import pkg from "../package.json" +import { initI18n, t } from "./i18n" +import { UPDATER_ENABLED } from "./updater" +import { webviewZoom } from "./webview-zoom" import "./styles.css" -import { commands, InitStep } from "./bindings" import { Channel } from "@tauri-apps/api/core" +import { createStore } from "solid-js/store" +import { commands, type InitStep } from "./bindings" import { createMenu } from "./menu" const root = document.getElementById("root") @@ -58,7 +60,7 @@ const listenForDeepLinks = async () => { await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) } -const createPlatform = (password: Accessor): Platform => { +const createPlatform = (): Platform => { const os = (() => { const type = ostype() if (type === "macos" || type === "windows" || type === "linux") return type @@ -344,22 +346,10 @@ const createPlatform = (password: Accessor): Platform => { }, fetch: (input, init) => { - const pw = password() - - const addHeader = (headers: Headers, password: string) => { - headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) - } - if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) return tauriFetch(input) } else { - const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) - return tauriFetch(input, { - ...(init as any), - headers: headers, - }) + return tauriFetch(input, init) } }, @@ -417,7 +407,11 @@ const createPlatform = (password: Accessor): Platform => { return new Promise((resolve) => { canvas.toBlob((blob) => { if (!blob) return resolve(null) - resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + resolve( + new File([blob], `pasted-image-${Date.now()}.png`, { + type: "image/png", + }), + ) }, "image/png") }) }, @@ -431,9 +425,7 @@ createMenu((id) => { void listenForDeepLinks() render(() => { - const [serverPassword, setServerPassword] = createSignal(null) - - const platform = createPlatform(() => serverPassword()) + const platform = createPlatform() function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null @@ -455,9 +447,17 @@ render(() => { {(data) => { - setServerPassword(data().password) - window.__OPENCODE__ ??= {} - window.__OPENCODE__.serverPassword = data().password ?? undefined + const [servers] = createStore>([ + { + type: "local", + variant: "base", + http: { + url: data().url, + username: "opencode", + password: data().password ?? undefined, + }, + }, + ]) function Inner() { const cmd = useCommand() @@ -468,7 +468,7 @@ render(() => { } return ( - + ) From 688d65faa3c375cdc84e42d9966d65aaec6ab858 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 16 Feb 2026 15:56:23 +0800 Subject: [PATCH 02/11] fix ts --- .../app/src/components/status-popover.tsx | 36 +++++++++---------- packages/app/tsconfig.json | 3 +- packages/sdk/js/package.json | 14 ++++++-- packages/sdk/js/tsconfig.json | 6 ++-- 4 files changed, 35 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 1d1743b5267f..376adc969582 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -12,7 +12,7 @@ import { ServerRow } from "@/components/server/server-row" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, useServer } from "@/context/server" +import { normalizeServerUrl, type ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { DialogSelectServer } from "./dialog-select-server" @@ -32,7 +32,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { } const listServersByHealth = ( - list: string[], + list: ServerConnection.Any[], active: string | undefined, status: Record, ) => { @@ -45,15 +45,15 @@ const listServersByHealth = ( } return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = rank(status[a]) - rank(status[b]) + if (a.http.url === active) return -1 + if (b.http.url === active) return 1 + const diff = rank(status[a.http.url]) - rank(status[b.http.url]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) } -const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => { +const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => { const [status, setStatus] = createStore({} as Record) createEffect(() => { @@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => const refresh = async () => { const results: Record = {} await Promise.all( - list.map(async (url) => { - results[url] = await checkServerHealth({ url }, fetcher) + list.map(async ({ http }) => { + results[http.url] = await checkServerHealth(http, fetcher) }), ) if (dead) return @@ -163,11 +163,11 @@ export function StatusPopover() { const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { - const current = server.url + const current = server.current const list = server.list if (!current) return list - // if (!list.includes(current)) return [current, ...list] - return [current /* ...list.filter((item) => item !== current) */] + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((item) => item !== current)] }) const health = useServerHealth(servers, fetcher) const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) @@ -249,8 +249,8 @@ export function StatusPopover() {
- {(url) => { - const isBlocked = () => health[url]?.healthy === false + {(s) => { + const isBlocked = () => health[s.http.url]?.healthy === false return (
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index f93bdb33bffb..7ca87c6f588e 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -1,10 +1,19 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" -import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" -import { serverDisplayName } from "@/context/server" +import { + createEffect, + createMemo, + createSignal, + type JSXElement, + onCleanup, + onMount, + type ParentProps, + Show, +} from "solid-js" +import { type ServerConnection, serverDisplayName } from "@/context/server" import type { ServerHealth } from "@/utils/server-health" interface ServerRowProps extends ParentProps { - url: string + conn: ServerConnection.Any status?: ServerHealth class?: string nameClass?: string @@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) { const [truncated, setTruncated] = createSignal(false) let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined - const name = createMemo(() => serverDisplayName(props.url)) + const name = createMemo(() => props.conn.displayName ?? serverDisplayName(props.conn.http.url)) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) { createEffect(() => { name() - props.url + props.conn.http.url props.status?.version queueMicrotask(check) }) diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index e7127604f21f..90db307ae398 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -166,8 +166,8 @@ export function StatusPopover() { const current = server.current const list = server.list if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((item) => item !== current)] + if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] + return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) const health = useServerHealth(servers, fetcher) const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) @@ -267,7 +267,7 @@ export function StatusPopover() { }} > , lastProject: {} as Record, }), ) - const allServers = (): Array => [ - ...(props.servers ?? []), - ...store.list.map((value) => ({ - type: "http" as const, - http: typeof value === "string" ? { url: value } : value, - })), - ] + const allServers = createMemo((): Array => { + const list = [ + ...(props.servers ?? []), + ...store.list.map((value) => ({ + type: "http" as const, + http: typeof value === "string" ? { url: value } : value, + })), + ] + console.log([...list]) + return list + }) const [state, setState] = createStore({ active: ServerConnection.key({ type: "http", http: { url: props.defaultUrl } }), @@ -146,13 +150,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( setState("active", url) } - function add(input: ServerConnection.HttpBase) { - const url = normalizeServerUrl(input.url) + function add(input: string) { + const url = normalizeServerUrl(input) if (!url) return return batch(() => { - const http: ServerConnection.HttpBase = { ...input, url } + const http: ServerConnection.HttpBase = { url } if (!store.list.includes(url)) { - setStore("list", store.list.length, http) + setStore("list", store.list.length, url) } const conn: ServerConnection.Http = { type: "http", http } setState("active", ServerConnection.key(conn)) @@ -193,7 +197,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") - const current = createMemo(() => allServers().find((s) => s.http.url === state.active)) + const current = createMemo(() => allServers().find((s) => s.http.url === state.active) ?? allServers()[0]) return { ready: isReady, @@ -209,7 +213,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( return allServers() }, get current() { - return current()! + return current() }, get http() { const c = current() diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index f676e9edbeb0..2e20af56dd2e 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -23,7 +23,7 @@ import { relaunch } from "@tauri-apps/plugin-process" import { open as shellOpen } from "@tauri-apps/plugin-shell" import { Store } from "@tauri-apps/plugin-store" import { check, type Update } from "@tauri-apps/plugin-updater" -import { type Accessor, createResource, createSignal, type JSX, onCleanup, onMount, Show } from "solid-js" +import { type Accessor, createResource, type JSX, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../package.json" import { initI18n, t } from "./i18n" @@ -449,6 +449,7 @@ render(() => { {(data) => { const [servers] = createStore>([ { + displayName: "Local Server", type: "sidecar", variant: "base", http: { From d7358f22ffb0efe0521e70d8f067e65bcfbad0da Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 18 Feb 2026 14:39:23 +0800 Subject: [PATCH 07/11] more correct --- .../app/src/components/dialog-select-server.tsx | 2 -- packages/app/src/components/terminal.tsx | 4 ++-- packages/app/src/context/global-sdk.tsx | 4 +++- packages/app/src/context/server.tsx | 15 ++++++++------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index cea046e93822..4f6b32f0f19e 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -10,7 +10,6 @@ import { useNavigate } from "@solidjs/router" import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerRow } from "@/components/server/server-row" -import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" @@ -171,7 +170,6 @@ export function DialogSelectServer() { const dialog = useDialog() const server = useServer() const platform = usePlatform() - const globalSDK = useGlobalSDK() const language = useLanguage() const fetcher = platform.fetch ?? globalThis.fetch const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index f1b871e98aff..085a796134b5 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -442,8 +442,8 @@ export const Terminal = (props: TerminalProps) => { url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.username = server.http?.username ?? "" - url.password = server.http?.password ?? "" + url.username = server.current?.http.username ?? "" + url.password = server.current?.http.password ?? "" const socket = new WebSocket(url) socket.binaryType = "arraybuffer" ws = socket diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index b8ab22f08fd4..175ceaf93fcc 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -204,8 +204,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo client: sdk, event: emitter, createClient(opts: Omit[0], "server" | "fetch">) { + const s = server.current + if (!s) throw new Error("Server not available") return createSdkForServer({ - server: server.current.http, + server: s.http, fetch: platform.fetch, ...opts, }) diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 11674d1ee9cd..f79aa459d072 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" @@ -113,7 +113,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) const [state, setState] = createStore({ - active: ServerConnection.key({ type: "http", http: { url: props.defaultUrl } }), + active: ServerConnection.key({ + type: "http", + http: { url: props.defaultUrl }, + }), healthy: undefined as boolean | undefined, }) @@ -197,7 +200,9 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") - const current = createMemo(() => allServers().find((s) => s.http.url === state.active) ?? allServers()[0]) + const current: Accessor = createMemo( + () => allServers().find((s) => s.http.url === state.active) ?? allServers()[0], + ) return { ready: isReady, @@ -215,10 +220,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( get current() { return current() }, - get http() { - const c = current() - return c?.http! - }, setActive, add, remove, From 78686fe87ac05746ad6a8080da6f48d9c47b2875 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 18 Feb 2026 16:01:38 +0800 Subject: [PATCH 08/11] more correct --- packages/app/src/app.tsx | 2 +- .../src/components/dialog-select-server.tsx | 30 +++++++++---------- .../app/src/components/server/server-row.tsx | 2 +- .../app/src/components/status-popover.tsx | 15 ++++++---- packages/app/src/context/global-sdk.tsx | 12 ++++---- packages/app/src/context/server.tsx | 16 +++++----- packages/desktop/src/index.tsx | 4 +-- 7 files changed, 42 insertions(+), 39 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b060df4343c6..a567531871b5 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -138,7 +138,7 @@ export function AppBaseProviders(props: ParentProps) { function ServerKey(props: ParentProps) { const server = useServer() return ( - + {props.children} ) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4f6b32f0f19e..fa5d2d36c7a4 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -176,7 +176,7 @@ export function DialogSelectServer() { const { previewStatus } = useServerPreview(fetcher) let listRoot: HTMLDivElement | undefined const [store, setStore] = createStore({ - status: {} as Record, + status: {} as Record, addServer: { url: "", adding: false, @@ -213,7 +213,7 @@ export function DialogSelectServer() { } const replaceServer = (original: ServerConnection.Http, next: string) => { - const active = server.url + const active = server.key const newConn = server.add(next) if (!newConn) return @@ -230,7 +230,7 @@ export function DialogSelectServer() { return [current, ...list.filter((x) => x !== current)] }) - const current = createMemo(() => items().find((x) => x.http.url === server.url) ?? items()[0]) + const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) const sortedItems = createMemo(() => { const list = items() @@ -245,17 +245,17 @@ export function DialogSelectServer() { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[a.http.url]) - rank(store.status[b.http.url]) + const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) }) async function refreshHealth() { - const results: Record = {} + const results: Record = {} await Promise.all( - items().map(async ({ http }) => { - results[http.url] = await checkServerHealth(http, fetcher) + items().map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) }), ) setStore("status", reconcile(results)) @@ -268,15 +268,15 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - async function select(value: ServerConnection.Any, persist?: boolean) { - if (!persist && store.status[value.http.url]?.healthy === false) return + async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return dialog.close() if (persist) { - server.add(value.http.url) + server.add(conn.http.url) navigate("/") return } - server.setActive(ServerConnection.key(value)) + server.setActive(ServerConnection.key(conn)) navigate("/") } @@ -446,8 +446,8 @@ export function DialogSelectServer() { > @@ -482,13 +482,13 @@ export function DialogSelectServer() { id: i.http.url, value: i.http.url, error: "", - status: store.status[i.http.url]?.healthy, + status: store.status[ServerConnection.key(i)]?.healthy, }) }} > {language.t("dialog.server.menu.edit")} - + setDefault(i.http.url)}> {language.t("dialog.server.menu.default")} diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 7ca87c6f588e..12dcebfa9715 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -26,7 +26,7 @@ export function ServerRow(props: ServerRowProps) { const [truncated, setTruncated] = createSignal(false) let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined - const name = createMemo(() => props.conn.displayName ?? serverDisplayName(props.conn.http.url)) + const name = createMemo(() => serverDisplayName(props.conn)) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 7c1e7c6814ab..2798a3d02883 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -33,7 +33,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { const listServersByHealth = ( list: ServerConnection.Any[], - active: string | undefined, + active: ServerConnection.Key | undefined, status: Record, ) => { if (!list.length) return list @@ -45,9 +45,9 @@ const listServersByHealth = ( } return list.slice().sort((a, b) => { - if (a.http.url === active) return -1 - if (b.http.url === active) return 1 - const diff = rank(status[a.http.url]) - rank(status[b.http.url]) + if (ServerConnection.key(a) === active) return -1 + if (ServerConnection.key(b) === active) return 1 + const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) @@ -170,7 +170,7 @@ export function StatusPopover() { return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) const health = useServerHealth(servers, fetcher) - const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) + const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const mcp = useMcpToggle({ sync, sdk, language }) const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) @@ -264,6 +264,7 @@ export function StatusPopover() { aria-disabled={isBlocked()} onClick={() => { if (isBlocked()) return + console.log("onClick") server.setActive(ServerConnection.key(s)) navigate("/") }} @@ -284,7 +285,9 @@ export function StatusPopover() { } >
- + diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 175ceaf93fcc..8c0035d555b7 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -19,9 +19,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const abort = new AbortController() const eventFetch = (() => { - if (!platform.fetch) return + if (!platform.fetch || !server.current) return try { - const url = new URL(server.url) + const url = new URL(server.current.http.url) const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" if (url.protocol === "http:" && !loopback) return platform.fetch } catch { @@ -35,7 +35,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, - server: server.current.http, + server: currentServer.http, }) const emitter = createGlobalEmitter<{ [key: string]: Event @@ -126,7 +126,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", { - url: server.url, + url: currentServer.http.url, fetch: eventFetch ? "platform" : "webview", error, }) @@ -159,7 +159,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (!aborted(error) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { - url: server.url, + url: currentServer.http.url, fetch: eventFetch ? "platform" : "webview", error, }) @@ -200,7 +200,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }) return { - url: server.url, + url: currentServer.http.url, client: sdk, event: emitter, createClient(opts: Omit[0], "server" | "fetch">) { diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index f79aa459d072..7988dff2a216 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -15,9 +15,10 @@ export function normalizeServerUrl(input: string) { return withProtocol.replace(/\/+$/, "") } -export function serverDisplayName(url: string) { - if (!url) return "" - return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") +export function serverDisplayName(conn?: ServerConnection.Any) { + if (!conn) return "" + if (conn.displayName) return conn.displayName + return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "") } function projectsKey(url: string) { @@ -148,9 +149,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } function setActive(input: ServerConnection.Key) { - const url = normalizeServerUrl(input) - if (!url) return - setState("active", url) + console.log("setActive", { input }) + if (state.active !== input) setState("active", input) } function add(input: string) { @@ -208,11 +208,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ready: isReady, healthy, isLocal, - get url() { + get key() { return state.active }, get name() { - return serverDisplayName(state.active) + return serverDisplayName(current()) }, get list() { return allServers() diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 2e20af56dd2e..3786f6a76a87 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -447,7 +447,7 @@ render(() => { {(data) => { - const [servers] = createStore>([ + const servers: Array = [ { displayName: "Local Server", type: "sidecar", @@ -458,7 +458,7 @@ render(() => { password: data().password ?? undefined, }, }, - ]) + ] function Inner() { const cmd = useCommand() From 1083794c44b9c0f4ea03e8407a1fba66c1ebfba6 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 18 Feb 2026 17:04:34 +0800 Subject: [PATCH 09/11] address review feedback --- packages/app/src/app.tsx | 8 ++-- .../app/src/components/status-popover.tsx | 35 +++++++++-------- packages/app/src/context/server.tsx | 38 +++++++------------ packages/app/src/entry.tsx | 4 +- 4 files changed, 40 insertions(+), 45 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index a567531871b5..1be9f38d7482 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -24,7 +24,7 @@ import { NotificationProvider } from "@/context/notification" import { PermissionProvider } from "@/context/permission" import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" -import { normalizeServerUrl, type ServerConnection, ServerProvider, useServer } from "@/context/server" +import { type ServerConnection, ServerProvider, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" import DirectoryLayout from "@/pages/directory-layout" @@ -146,11 +146,11 @@ function ServerKey(props: ParentProps) { export function AppInterface(props: { children?: JSX.Element - defaultUrl: string - servers?: Array + defaultServer: ServerConnection.Key + servers?: Array }) { return ( - + diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 2798a3d02883..006b15780a5a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -34,7 +34,7 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { const listServersByHealth = ( list: ServerConnection.Any[], active: ServerConnection.Key | undefined, - status: Record, + status: Record, ) => { if (!list.length) return list const order = new Map(list.map((url, index) => [url, index] as const)) @@ -54,7 +54,7 @@ const listServersByHealth = ( } const useServerHealth = (servers: Accessor, fetcher: typeof fetch) => { - const [status, setStatus] = createStore({} as Record) + const [status, setStatus] = createStore({} as Record) createEffect(() => { const list = servers() @@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor, fetcher: typ const refresh = async () => { const results: Record = {} await Promise.all( - list.map(async ({ http }) => { - results[http.url] = await checkServerHealth(http, fetcher) + list.map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) }), ) if (dead) return @@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor, fetcher: typ return status } -const useDefaultServerUrl = ( +const useDefaultServerKey = ( get: (() => string | Promise | null | undefined) | undefined, ) => { const [url, setUrl] = createSignal() @@ -117,7 +117,14 @@ const useDefaultServerUrl = ( }) }) - return { url, refresh: () => setTick((value) => value + 1) } + return { + key: () => { + const u = url() + if (!u) return + return ServerConnection.key({ type: "http", http: { url: u } }) + }, + refresh: () => setTick((value) => value + 1), + } } const useMcpToggle = (input: { @@ -172,7 +179,7 @@ export function StatusPopover() { const health = useServerHealth(servers, fetcher) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const mcp = useMcpToggle({ sync, sdk, language }) - const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) + const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) 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) @@ -252,7 +259,8 @@ export function StatusPopover() {
{(s) => { - const isBlocked = () => health[s.http.url]?.healthy === false + const key = ServerConnection.key(s) + const isBlocked = () => health[key]?.healthy === false return (