From 67ea21b55a281d6feb00820f1fec94da50165678 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 24 Jan 2026 19:03:36 +0100 Subject: feat(web): implement new server management for web and desktop (#8513) --- .../app/src/components/dialog-select-server.tsx | 531 ++++++++++++++++----- .../app/src/components/session-lsp-indicator.tsx | 42 -- .../app/src/components/session-mcp-indicator.tsx | 34 -- .../app/src/components/session/session-header.tsx | 162 +++---- packages/app/src/components/status-popover.tsx | 364 ++++++++++++++ 5 files changed, 838 insertions(+), 295 deletions(-) delete mode 100644 packages/app/src/components/session-lsp-indicator.tsx delete mode 100644 packages/app/src/components/session-mcp-indicator.tsx create mode 100644 packages/app/src/components/status-popover.tsx (limited to 'packages/app/src/components') diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4466fdde1..e62aa93be 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,23 +1,47 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" -import { TextField } from "@opencode-ai/ui/text-field" import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" +import { Popover } from "@opencode-ai/ui/popover" +import { useGlobalSDK } from "@/context/global-sdk" type ServerStatus = { healthy: boolean; version?: string } -async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise { +interface AddRowProps { + value: string + placeholder: string + adding: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +interface EditRowProps { + value: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +async function checkHealth(url: string, platform: ReturnType): Promise { const sdk = createOpencodeClient({ baseUrl: url, - fetch, + fetch: platform.fetch, signal: AbortSignal.timeout(3000), }) return sdk.global @@ -26,21 +50,139 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis .catch(() => ({ healthy: false })) } +function AddRow(props: AddRowProps) { + return ( +
+
+
+ +
+
+ ) +} + +function EditRow(props: EditRowProps) { + return ( +
event.stopPropagation()}> +
+
+ +
+
+ ) +} + export function DialogSelectServer() { const navigate = useNavigate() const dialog = useDialog() const server = useServer() const platform = usePlatform() + const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - url: "", - adding: false, - error: "", status: {} as Record, + addServer: { + url: "", + adding: false, + error: "", + showForm: false, + status: undefined as boolean | undefined, + }, + editServer: { + id: undefined as string | undefined, + value: "", + error: "", + busy: false, + status: undefined as boolean | undefined, + }, }) const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) const isDesktop = platform.platform === "desktop" + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkHealth(normalized, platform) + setStatus(result.healthy) + } + + const resetAdd = () => { + setStore("addServer", { + url: "", + error: "", + showForm: false, + status: undefined, + }) + } + + const resetEdit = () => { + setStore("editServer", { + id: undefined, + value: "", + error: "", + status: undefined, + busy: false, + }) + } + + const replaceServer = (original: string, next: string) => { + const active = server.url + const nextActive = active === original ? next : active + + server.add(next) + if (nextActive) server.setActive(nextActive) + server.remove(original) + } + const items = createMemo(() => { const current = server.url const list = server.list @@ -74,7 +216,7 @@ export function DialogSelectServer() { const results: Record = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform.fetch) + results[url] = await checkHealth(url, platform) }), ) setStore("status", reconcile(results)) @@ -87,7 +229,7 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - function select(value: string, persist?: boolean) { + async function select(value: string, persist?: boolean) { if (!persist && store.status[value]?.healthy === false) return dialog.close() if (persist) { @@ -99,24 +241,101 @@ export function DialogSelectServer() { navigate("/") } - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const value = normalizeServerUrl(store.url) - if (!value) return + const handleAddChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { url: value, error: "" }) + void previewStatus(value, (next) => setStore("addServer", { status: next })) + } + + const scrollListToBottom = () => { + const scroll = document.querySelector('[data-component="list"] [data-slot="list-scroll"]') + if (!scroll) return + requestAnimationFrame(() => { + scroll.scrollTop = scroll.scrollHeight + }) + } + + const handleEditChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, (next) => setStore("editServer", { status: next })) + } + + async function handleAdd(value: string) { + if (store.addServer.adding) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } - setStore("adding", true) - setStore("error", "") + setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(value, platform.fetch) - setStore("adding", false) + const result = await checkHealth(normalized, platform) + setStore("addServer", { adding: false }) if (!result.healthy) { - setStore("error", language.t("dialog.server.add.error")) + setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - setStore("url", "") - select(value, true) + resetAdd() + await select(normalized, true) + } + + async function handleEdit(original: string, value: string) { + if (store.editServer.busy) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetEdit() + return + } + + if (normalized === original) { + resetEdit() + return + } + + setStore("editServer", { busy: true, error: "" }) + + const result = await checkHealth(normalized, platform) + setStore("editServer", { busy: false }) + + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + + replaceServer(original, normalized) + + resetEdit() + } + + const handleAddKey = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleAdd(store.addServer.url) + } + + const blurAdd = () => { + if (!store.addServer.url.trim()) { + resetAdd() + return + } + handleAdd(store.addServer.url) + } + + const handleEditKey = (event: KeyboardEvent, original: string) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + resetEdit() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleEdit(original, store.editServer.value) } async function handleRemove(url: string) { @@ -124,125 +343,185 @@ export function DialogSelectServer() { } return ( - -
+ +
x} - current={current()} onSelect={(x) => { if (x) select(x) }} + divider={true} + class="[&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:py-3" + add={ + store.addServer.showForm + ? { + render: () => ( + + ), + } + : undefined + } > - {(i) => ( -
-
-
- {serverDisplayName(i)} - {store.status[i]?.version} + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( +
+ handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > +
+
+ {serverDisplayName(i)} + + {store.status[i]?.version} + + + + {language.t("dialog.server.status.default")} + + +
+ + +
+ +

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

+
+ +
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> + event.stopPropagation()} + /> + } + class="w-max !min-w-fit !max-w-none" + > +
+ + + + + + + +
+ +
+ +
+
+
- - { - e.stopPropagation() - handleRemove(i) - }} - /> - -
- )} + ) + }} -
-
-

{language.t("dialog.server.add.title")}

-
-
-
-
- { - setStore("url", v) - setStore("error", "") - }} - validationState={store.error ? "invalid" : "valid"} - error={store.error} - /> -
- -
-
+
+
- - -
-
-

{language.t("dialog.server.default.title")}

-

{language.t("dialog.server.default.description")}

-
-
- {language.t("dialog.server.default.none")} - } - > - - - } - > -
- {serverDisplayName(defaultUrl()!)} -
- - -
-
-
) diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx deleted file mode 100644 index dab92920e..000000000 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useLanguage } from "@/context/language" -import { Tooltip } from "@opencode-ai/ui/tooltip" - -export function SessionLspIndicator() { - const sync = useSync() - const language = useLanguage() - - const lspStats = createMemo(() => { - const lsp = sync.data.lsp ?? [] - const connected = lsp.filter((s) => s.status === "connected").length - const hasError = lsp.some((s) => s.status === "error") - const total = lsp.length - return { connected, hasError, total } - }) - - const tooltipContent = createMemo(() => { - const lsp = sync.data.lsp ?? [] - if (lsp.length === 0) return language.t("lsp.tooltip.none") - return lsp.map((s) => s.name).join(", ") - }) - - return ( - 0}> - -
-
0, - }} - /> - - {language.t("lsp.label.connected", { count: lspStats().connected })} - -
- - - ) -} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx deleted file mode 100644 index 489223b9b..000000000 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useSync } from "@/context/sync" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" - -export function SessionMcpIndicator() { - const sync = useSync() - const dialog = useDialog() - - const mcpStats = createMemo(() => { - const mcp = sync.data.mcp ?? {} - const entries = Object.entries(mcp) - const enabled = entries.filter(([, status]) => status.status === "connected").length - const failed = entries.some(([, status]) => status.status === "failed") - const total = entries.length - return { enabled, failed, total } - }) - - return ( - 0}> - - - ) -} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1e06e8ed6..4b7f9f4ad 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -20,14 +20,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { StatusPopover } from "../status-popover" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() const command = useCommand() - // const server = useServer() - // const dialog = useDialog() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -154,96 +153,7 @@ export function SessionHeader() { {(mount) => (
- {/* */} -
- - -
+
-
+ +
)} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx new file mode 100644 index 000000000..5b65f62bc --- /dev/null +++ b/packages/app/src/components/status-popover.tsx @@ -0,0 +1,364 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useNavigate } from "@solidjs/router" +import { useDialog } from "@opencode-ai/ui/context/dialog" +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 { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { DialogSelectServer } from "./dialog-select-server" + +type ServerStatus = { healthy: boolean; version?: string } + +async function checkHealth(url: string, platform: ReturnType): Promise { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch: platform.fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} + +export function StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const [loading, setLoading] = createSignal(null) + const [store, setStore] = createStore({ + status: {} as Record, + }) + + const servers = createMemo(() => { + const current = server.url + const list = server.list + if (!current) return list + if (!list.includes(current)) return [current, ...list] + return [current, ...list.filter((x) => x !== current)] + }) + + const sortedServers = createMemo(() => { + const list = servers() + if (!list.length) return list + const active = server.url + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerStatus) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + 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]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record = {} + await Promise.all( + servers().map(async (url) => { + results[url] = await checkHealth(url, platform) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + servers() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) + + const toggleMcp = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const lspItems = createMemo(() => sync.data.lsp ?? []) + const lspCount = createMemo(() => lspItems().length) + const plugins = createMemo(() => sync.data.config.plugin ?? []) + const pluginCount = createMemo(() => plugins().length) + + const overallHealthy = createMemo(() => { + const serverHealthy = server.healthy() === true + const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled") + return serverHealthy && !anyMcpIssue + }) + + const serverCount = createMemo(() => sortedServers().length) + + const [defaultServerUrl, setDefaultServerUrl] = createSignal() + + createEffect(() => { + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) { + result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + return + } + if (result) setDefaultServerUrl(normalizeServerUrl(result)) + }) + + return ( + +
+ Status +
+ } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] mx-5 bg-transparent border-0 shadow-none rounded-xl" + gutter={8} + > +
+ + + + {serverCount() > 0 ? `${serverCount()} ` : ""}Servers + + + {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP + + + {lspCount() > 0 ? `${lspCount()} ` : ""}LSP + + + {pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins + + + + +
+
+ + {(url) => { + const isActive = () => url === server.url + const isDefault = () => url === defaultServerUrl() + const status = () => store.status[url] + const isBlocked = () => status()?.healthy === false + return ( + + ) + }} + + + +
+
+
+ + +
+
+ 0} + fallback={ +
No MCP servers configured
+ } + > + + {(item) => { + const enabled = () => item.status === "connected" + return ( + + ) + }} + +
+
+
+
+ + +
+
+ 0} + fallback={ +
+ LSPs auto-detected from file types +
+ } + > + + {(item) => ( +
+
+ {item.name || item.id} +
+ )} + + +
+
+ + + +
+
+ 0} + fallback={ +
+ Plugins configured in{" "} + opencode.json +
+ } + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + + +
+
+ + +
+ + ) +} -- cgit v1.2.3