summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-26 13:41:22 -0500
committerGitHub <[email protected]>2026-03-26 13:41:22 -0500
commitc7760b433b1bdbcaed7e7cd55d53b5b331f0f0fa (patch)
tree4d1a865b8890dc30767b66293923c15e2b3f6d24 /packages/app/src/components
parent2e6ac8ff49eabcb1b62c1bd504338e7449f80c6e (diff)
downloadopencode-c7760b433b1bdbcaed7e7cd55d53b5b331f0f0fa.tar.gz
opencode-c7760b433b1bdbcaed7e7cd55d53b5b331f0f0fa.zip
fix(app): more startup perf (#19288)
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-connect-provider.tsx19
-rw-r--r--packages/app/src/components/dialog-select-mcp.tsx46
-rw-r--r--packages/app/src/components/dialog-select-model-unpaid.tsx20
-rw-r--r--packages/app/src/components/dialog-select-model.tsx35
-rw-r--r--packages/app/src/components/prompt-input.tsx7
-rw-r--r--packages/app/src/components/session-context-usage.tsx4
-rw-r--r--packages/app/src/components/session/session-context-tab.tsx4
-rw-r--r--packages/app/src/components/status-popover-body.tsx443
-rw-r--r--packages/app/src/components/status-popover.tsx410
9 files changed, 569 insertions, 419 deletions
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index e7eaa1fb2..41225d02a 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -15,13 +15,20 @@ import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
+ const providers = useProviders()
+
+ const all = () => {
+ void import("./dialog-select-provider").then((x) => {
+ dialog.show(() => <x.DialogSelectProvider />)
+ })
+ }
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -33,7 +40,11 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = undefined
})
- const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
+ const provider = createMemo(
+ () =>
+ providers.all().find((x) => x.id === props.provider) ??
+ globalSync.data.provider.all.find((x) => x.id === props.provider)!,
+ )
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
type: "api" as const,
@@ -333,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
function goBack() {
if (methods().length === 1) {
- dialog.show(() => <DialogSelectProvider />)
+ all()
return
}
if (store.authorization) {
@@ -344,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.reset" })
return
}
- dialog.show(() => <DialogSelectProvider />)
+ all()
}
function MethodSelection() {
diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx
index fafba6168..98f262ce5 100644
--- a/packages/app/src/components/dialog-select-mcp.tsx
+++ b/packages/app/src/components/dialog-select-mcp.tsx
@@ -1,10 +1,12 @@
import { useMutation } from "@tanstack/solid-query"
-import { Component, createMemo, Show } from "solid-js"
+import { Component, createEffect, createMemo, on, Show } from "solid-js"
+import { createStore } from "solid-js/store"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
+import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
const statusLabels = {
@@ -18,6 +20,48 @@ export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const language = useLanguage()
+ const [state, setState] = createStore({
+ done: false,
+ loading: false,
+ })
+
+ createEffect(
+ on(
+ () => sync.data.mcp_ready,
+ (ready, prev) => {
+ if (!ready && prev) setState("done", false)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(() => {
+ if (state.done || state.loading) return
+ if (sync.data.mcp_ready) {
+ setState("done", true)
+ return
+ }
+
+ setState("loading", true)
+ void sdk.client.mcp
+ .status()
+ .then((result) => {
+ sync.set("mcp", result.data ?? {})
+ sync.set("mcp_ready", true)
+ setState("done", true)
+ })
+ .catch((err) => {
+ setState("done", true)
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ })
+ .finally(() => {
+ setState("loading", false)
+ })
+ })
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx
index 2106b3a01..e25e8f0c1 100644
--- a/packages/app/src/components/dialog-select-model-unpaid.tsx
+++ b/packages/app/src/components/dialog-select-model-unpaid.tsx
@@ -8,8 +8,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
-import { DialogConnectProvider } from "./dialog-connect-provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
@@ -21,6 +19,18 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
const providers = useProviders()
const language = useLanguage()
+ const connect = (provider: string) => {
+ void import("./dialog-connect-provider").then((x) => {
+ dialog.show(() => <x.DialogConnectProvider provider={provider} />)
+ })
+ }
+
+ const all = () => {
+ void import("./dialog-select-provider").then((x) => {
+ dialog.show(() => <x.DialogSelectProvider />)
+ })
+ }
+
let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return
@@ -91,7 +101,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
}}
onSelect={(x) => {
if (!x) return
- dialog.show(() => <DialogConnectProvider provider={x.id} />)
+ connect(x.id)
}}
>
{(i) => (
@@ -122,9 +132,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
- onClick={() => {
- dialog.show(() => <DialogSelectProvider />)
- }}
+ onClick={all}
>
{language.t("dialog.provider.viewAll")}
</Button>
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 3654aab85..cb688c30a 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -10,8 +10,6 @@ import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { DialogSelectProvider } from "./dialog-select-provider"
-import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
@@ -107,12 +105,16 @@ export function ModelSelectorPopover(props: {
const handleManage = () => {
setStore("open", false)
- dialog.show(() => <DialogManageModels />)
+ void import("./dialog-manage-models").then((x) => {
+ dialog.show(() => <x.DialogManageModels />)
+ })
}
const handleConnectProvider = () => {
setStore("open", false)
- dialog.show(() => <DialogSelectProvider />)
+ void import("./dialog-select-provider").then((x) => {
+ dialog.show(() => <x.DialogSelectProvider />)
+ })
}
const language = useLanguage()
@@ -193,26 +195,29 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
const dialog = useDialog()
const language = useLanguage()
+ const provider = () => {
+ void import("./dialog-select-provider").then((x) => {
+ dialog.show(() => <x.DialogSelectProvider />)
+ })
+ }
+
+ const manage = () => {
+ void import("./dialog-manage-models").then((x) => {
+ dialog.show(() => <x.DialogManageModels />)
+ })
+ }
+
return (
<Dialog
title={language.t("dialog.model.select.title")}
action={
- <Button
- class="h-7 -my-1 text-14-medium"
- icon="plus-small"
- tabIndex={-1}
- onClick={() => dialog.show(() => <DialogSelectProvider />)}
- >
+ <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
{language.t("command.provider.connect")}
</Button>
}
>
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
- <Button
- variant="ghost"
- class="ml-3 mt-5 mb-6 text-text-base self-start"
- onClick={() => dialog.show(() => <DialogManageModels />)}
- >
+ <Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
{language.t("dialog.model.manage")}
</Button>
</Dialog>
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index ee98e68cd..1cc7c578d 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
-import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
@@ -1494,7 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
- onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
+ onClick={() => {
+ void import("@/components/dialog-select-model-unpaid").then((x) => {
+ dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
+ })
+ }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index 7379833f8..d7c249ab0 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -7,6 +7,7 @@ import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
+import { useProviders } from "@/hooks/use-providers"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers"
@@ -32,6 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const file = useFile()
const layout = useLayout()
const language = useLanguage()
+ const providers = useProviders()
const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button")
@@ -50,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
- const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 4d90930a0..4e7dc8e78 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -12,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
+import { useProviders } from "@/hooks/use-providers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
@@ -92,6 +93,7 @@ const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const sync = useSync()
const language = useLanguage()
+ const providers = useProviders()
const { params, view } = useSessionLayout()
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
@@ -130,7 +132,7 @@ export function SessionContextTab() {
}),
)
- const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
new file mode 100644
index 000000000..aaf9f58d6
--- /dev/null
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -0,0 +1,443 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Switch } from "@opencode-ai/ui/switch"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useMutation } from "@tanstack/solid-query"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useNavigate } from "@solidjs/router"
+import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useSDK } from "@/context/sdk"
+import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { useSync } from "@/context/sync"
+import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
+
+const pollMs = 10_000
+
+const pluginEmptyMessage = (value: string, file: string): JSXElement => {
+ const parts = value.split(file)
+ if (parts.length === 1) return value
+ return (
+ <>
+ {parts[0]}
+ <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
+ {parts.slice(1).join(file)}
+ </>
+ )
+}
+
+const listServersByHealth = (
+ list: ServerConnection.Any[],
+ active: ServerConnection.Key | undefined,
+ status: Record<ServerConnection.Key, ServerHealth | undefined>,
+) => {
+ if (!list.length) return list
+ const order = new Map(list.map((url, index) => [url, index] as const))
+ const rank = (value?: ServerHealth) => {
+ if (value?.healthy === true) return 0
+ if (value?.healthy === false) return 2
+ return 1
+ }
+
+ return list.slice().sort((a, b) => {
+ 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)
+ })
+}
+
+const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
+ const checkServerHealth = useCheckServerHealth()
+ const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
+
+ createEffect(() => {
+ if (!enabled()) {
+ setStatus(reconcile({}))
+ return
+ }
+ const list = servers()
+ let dead = false
+
+ const refresh = async () => {
+ const results: Record<string, ServerHealth> = {}
+ await Promise.all(
+ list.map(async (conn) => {
+ results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
+ }),
+ )
+ if (dead) return
+ setStatus(reconcile(results))
+ }
+
+ void refresh()
+ const id = setInterval(() => void refresh(), pollMs)
+ onCleanup(() => {
+ dead = true
+ clearInterval(id)
+ })
+ })
+
+ return status
+}
+
+const useDefaultServerKey = (
+ get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
+) => {
+ const [state, setState] = createStore({
+ url: undefined as string | undefined,
+ tick: 0,
+ })
+
+ createEffect(() => {
+ state.tick
+ let dead = false
+ const result = get?.()
+ if (!result) {
+ setState("url", undefined)
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ if (result instanceof Promise) {
+ void result.then((next) => {
+ if (dead) return
+ setState("url", next ? normalizeServerUrl(next) : undefined)
+ })
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ setState("url", normalizeServerUrl(result))
+ onCleanup(() => {
+ dead = true
+ })
+ })
+
+ return {
+ key: () => {
+ const u = state.url
+ if (!u) return
+ return ServerConnection.key({ type: "http", http: { url: u } })
+ },
+ refresh: () => setState("tick", (value) => value + 1),
+ }
+}
+
+const useMcpToggleMutation = () => {
+ const sync = useSync()
+ const sdk = useSDK()
+ const language = useLanguage()
+
+ return useMutation(() => ({
+ mutationFn: async (name: string) => {
+ const status = sync.data.mcp[name]
+ await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
+ const result = await sdk.client.mcp.status()
+ if (result.data) sync.set("mcp", result.data)
+ },
+ onError: (err) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ },
+ }))
+}
+
+export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
+ const sync = useSync()
+ const server = useServer()
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const language = useLanguage()
+ const navigate = useNavigate()
+ const sdk = useSDK()
+
+ const [load, setLoad] = createStore({
+ lspDone: false,
+ lspLoading: false,
+ mcpDone: false,
+ mcpLoading: false,
+ })
+
+ const fail = (err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ }
+
+ createEffect(() => {
+ if (!props.shown()) return
+
+ if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
+ setLoad("mcpLoading", true)
+ void sdk.client.mcp
+ .status()
+ .then((result) => {
+ sync.set("mcp", result.data ?? {})
+ sync.set("mcp_ready", true)
+ })
+ .catch((err) => {
+ setLoad("mcpDone", true)
+ fail(err)
+ })
+ .finally(() => {
+ setLoad("mcpLoading", false)
+ })
+ }
+
+ if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
+ setLoad("lspLoading", true)
+ void sdk.client.lsp
+ .status()
+ .then((result) => {
+ sync.set("lsp", result.data ?? [])
+ sync.set("lsp_ready", true)
+ })
+ .catch((err) => {
+ setLoad("lspDone", true)
+ fail(err)
+ })
+ .finally(() => {
+ setLoad("lspLoading", false)
+ })
+ }
+ })
+
+ let dialogRun = 0
+ let dialogDead = false
+ onCleanup(() => {
+ dialogDead = true
+ dialogRun += 1
+ })
+ const servers = createMemo(() => {
+ const current = server.current
+ const list = server.list
+ if (!current) return list
+ 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, props.shown)
+ const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
+ const toggleMcp = useMcpToggleMutation()
+ const defaultServer = useDefaultServerKey(platform.getDefaultServer)
+ const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
+ const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
+ const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
+ const lspItems = createMemo(() => sync.data.lsp ?? [])
+ const lspCount = createMemo(() => lspItems().length)
+ const plugins = createMemo(() => sync.data.config.plugin ?? [])
+ const pluginCount = createMemo(() => plugins().length)
+ const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
+
+ return (
+ <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
+ <Tabs
+ aria-label={language.t("status.popover.ariaLabel")}
+ class="tabs bg-background-strong rounded-xl overflow-hidden"
+ data-component="tabs"
+ data-active="servers"
+ defaultValue="servers"
+ variant="alt"
+ >
+ <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
+ <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
+ {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
+ {language.t("status.popover.tab.servers")}
+ </Tabs.Trigger>
+ <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
+ {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
+ {language.t("status.popover.tab.mcp")}
+ </Tabs.Trigger>
+ <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
+ {lspCount() > 0 ? `${lspCount()} ` : ""}
+ {language.t("status.popover.tab.lsp")}
+ </Tabs.Trigger>
+ <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
+ {pluginCount() > 0 ? `${pluginCount()} ` : ""}
+ {language.t("status.popover.tab.plugins")}
+ </Tabs.Trigger>
+ </Tabs.List>
+
+ <Tabs.Content value="servers">
+ <div class="flex flex-col px-2 pb-2">
+ <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+ <For each={sortedServers()}>
+ {(s) => {
+ const key = ServerConnection.key(s)
+ const blocked = () => health[key]?.healthy === false
+ return (
+ <button
+ type="button"
+ class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+ classList={{
+ "hover:bg-surface-raised-base-hover": !blocked(),
+ "cursor-not-allowed": blocked(),
+ }}
+ aria-disabled={blocked()}
+ onClick={() => {
+ if (blocked()) return
+ navigate("/")
+ queueMicrotask(() => server.setActive(key))
+ }}
+ >
+ <ServerHealthIndicator health={health[key]} />
+ <ServerRow
+ conn={s}
+ dimmed={blocked()}
+ status={health[key]}
+ class="flex items-center gap-2 w-full min-w-0"
+ nameClass="text-14-regular text-text-base truncate"
+ versionClass="text-12-regular text-text-weak truncate"
+ badge={
+ <Show when={key === defaultServer.key()}>
+ <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+ {language.t("common.default")}
+ </span>
+ </Show>
+ }
+ >
+ <div class="flex-1" />
+ <Show when={server.current && key === ServerConnection.key(server.current)}>
+ <Icon name="check" size="small" class="text-icon-weak shrink-0" />
+ </Show>
+ </ServerRow>
+ </button>
+ )
+ }}
+ </For>
+
+ <Button
+ variant="secondary"
+ class="mt-3 self-start h-8 px-3 py-1.5"
+ onClick={() => {
+ const run = ++dialogRun
+ void import("./dialog-select-server").then((x) => {
+ if (dialogDead || dialogRun !== run) return
+ dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
+ })
+ }}
+ >
+ {language.t("status.popover.action.manageServers")}
+ </Button>
+ </div>
+ </div>
+ </Tabs.Content>
+
+ <Tabs.Content value="mcp">
+ <div class="flex flex-col px-2 pb-2">
+ <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+ <Show
+ when={mcpNames().length > 0}
+ fallback={
+ <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
+ }
+ >
+ <For each={mcpNames()}>
+ {(name) => {
+ const status = () => mcpStatus(name)
+ const enabled = () => status() === "connected"
+ return (
+ <button
+ type="button"
+ class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
+ onClick={() => {
+ if (toggleMcp.isPending) return
+ toggleMcp.mutate(name)
+ }}
+ disabled={toggleMcp.isPending && toggleMcp.variables === name}
+ >
+ <div
+ classList={{
+ "size-1.5 rounded-full shrink-0": true,
+ "bg-icon-success-base": status() === "connected",
+ "bg-icon-critical-base": status() === "failed",
+ "bg-border-weak-base": status() === "disabled",
+ "bg-icon-warning-base":
+ status() === "needs_auth" || status() === "needs_client_registration",
+ }}
+ />
+ <span class="text-14-regular text-text-base truncate flex-1">{name}</span>
+ <div onClick={(event) => event.stopPropagation()}>
+ <Switch
+ checked={enabled()}
+ disabled={toggleMcp.isPending && toggleMcp.variables === name}
+ onChange={() => {
+ if (toggleMcp.isPending) return
+ toggleMcp.mutate(name)
+ }}
+ />
+ </div>
+ </button>
+ )
+ }}
+ </For>
+ </Show>
+ </div>
+ </div>
+ </Tabs.Content>
+
+ <Tabs.Content value="lsp">
+ <div class="flex flex-col px-2 pb-2">
+ <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+ <Show
+ when={lspItems().length > 0}
+ fallback={
+ <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
+ }
+ >
+ <For each={lspItems()}>
+ {(item) => (
+ <div class="flex items-center gap-2 w-full px-2 py-1">
+ <div
+ classList={{
+ "size-1.5 rounded-full shrink-0": true,
+ "bg-icon-success-base": item.status === "connected",
+ "bg-icon-critical-base": item.status === "error",
+ }}
+ />
+ <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
+ </div>
+ )}
+ </For>
+ </Show>
+ </div>
+ </div>
+ </Tabs.Content>
+
+ <Tabs.Content value="plugins">
+ <div class="flex flex-col px-2 pb-2">
+ <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
+ <Show
+ when={plugins().length > 0}
+ fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
+ >
+ <For each={plugins()}>
+ {(plugin) => (
+ <div class="flex items-center gap-2 w-full px-2 py-1">
+ <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
+ <span class="text-14-regular text-text-base truncate">{plugin}</span>
+ </div>
+ )}
+ </For>
+ </Show>
+ </div>
+ </div>
+ </Tabs.Content>
+ </Tabs>
+ </div>
+ )
+}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 8d5ecac39..6820a940b 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -1,202 +1,24 @@
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 { Switch } from "@opencode-ai/ui/switch"
-import { Tabs } from "@opencode-ai/ui/tabs"
-import { useMutation } from "@tanstack/solid-query"
-import { showToast } from "@opencode-ai/ui/toast"
-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 { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
+import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { useLanguage } from "@/context/language"
-import { usePlatform } from "@/context/platform"
-import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
+import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
-import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
-const pollMs = 10_000
-
-const pluginEmptyMessage = (value: string, file: string): JSXElement => {
- const parts = value.split(file)
- if (parts.length === 1) return value
- return (
- <>
- {parts[0]}
- <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
- {parts.slice(1).join(file)}
- </>
- )
-}
-
-const listServersByHealth = (
- list: ServerConnection.Any[],
- active: ServerConnection.Key | undefined,
- status: Record<ServerConnection.Key, ServerHealth | undefined>,
-) => {
- if (!list.length) return list
- const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerHealth) => {
- if (value?.healthy === true) return 0
- if (value?.healthy === false) return 2
- return 1
- }
-
- return list.slice().sort((a, b) => {
- 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)
- })
-}
-
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
- const checkServerHealth = useCheckServerHealth()
- const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
-
- createEffect(() => {
- if (!enabled()) {
- setStatus(reconcile({}))
- return
- }
- const list = servers()
- let dead = false
-
- const refresh = async () => {
- const results: Record<string, ServerHealth> = {}
- await Promise.all(
- list.map(async (conn) => {
- results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
- }),
- )
- if (dead) return
- setStatus(reconcile(results))
- }
-
- void refresh()
- const id = setInterval(() => void refresh(), pollMs)
- onCleanup(() => {
- dead = true
- clearInterval(id)
- })
- })
-
- return status
-}
-
-const useDefaultServerKey = (
- get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
-) => {
- const [state, setState] = createStore({
- url: undefined as string | undefined,
- tick: 0,
- })
-
- createEffect(() => {
- state.tick
- let dead = false
- const result = get?.()
- if (!result) {
- setState("url", undefined)
- onCleanup(() => {
- dead = true
- })
- return
- }
-
- if (result instanceof Promise) {
- void result.then((next) => {
- if (dead) return
- setState("url", next ? normalizeServerUrl(next) : undefined)
- })
- onCleanup(() => {
- dead = true
- })
- return
- }
-
- setState("url", normalizeServerUrl(result))
- onCleanup(() => {
- dead = true
- })
- })
-
- return {
- key: () => {
- const u = state.url
- if (!u) return
- return ServerConnection.key({ type: "http", http: { url: u } })
- },
- refresh: () => setState("tick", (value) => value + 1),
- }
-}
-
-const useMcpToggleMutation = () => {
- const sync = useSync()
- const sdk = useSDK()
- const language = useLanguage()
-
- return useMutation(() => ({
- mutationFn: async (name: string) => {
- const status = sync.data.mcp[name]
- await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
- const result = await sdk.client.mcp.status()
- if (result.data) sync.set("mcp", result.data)
- },
- onError: (err) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- },
- }))
-}
+const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
export function StatusPopover() {
- const sync = useSync()
- const server = useServer()
- const platform = usePlatform()
- const dialog = useDialog()
const language = useLanguage()
- const navigate = useNavigate()
-
+ const server = useServer()
+ const sync = useSync()
const [shown, setShown] = createSignal(false)
- let dialogRun = 0
- let dialogDead = false
- onCleanup(() => {
- dialogDead = true
- dialogRun += 1
- })
- const servers = createMemo(() => {
- const current = server.current
- const list = server.list
- if (!current) return list
- 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, shown)
- const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
- const toggleMcp = useMcpToggleMutation()
- const defaultServer = useDefaultServerKey(platform.getDefaultServer)
- const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
- const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
- const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
- const lspItems = createMemo(() => sync.data.lsp ?? [])
- const lspCount = createMemo(() => lspItems().length)
- const plugins = createMemo(() => sync.data.config.plugin ?? [])
- const pluginCount = createMemo(() => plugins().length)
- const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
- const overallHealthy = createMemo(() => {
+ const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
+ const healthy = createMemo(() => {
const serverHealthy = server.healthy() === true
- const anyMcpIssue = mcpNames().some((name) => {
- const status = mcpStatus(name)
- return status !== "connected" && status !== "disabled"
- })
- return serverHealthy && !anyMcpIssue
+ const mcp = Object.values(sync.data.mcp ?? {})
+ const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
+ return serverHealthy && !issue
})
return (
@@ -218,9 +40,9 @@ export function StatusPopover() {
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
- "bg-icon-success-base": overallHealthy(),
- "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
- "bg-border-weak-base": server.healthy() === undefined,
+ "bg-icon-success-base": ready() && healthy(),
+ "bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
+ "bg-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
</div>
@@ -230,205 +52,15 @@ export function StatusPopover() {
placement="bottom-end"
shift={-168}
>
- <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
- <Tabs
- aria-label={language.t("status.popover.ariaLabel")}
- class="tabs bg-background-strong rounded-xl overflow-hidden"
- data-component="tabs"
- data-active="servers"
- defaultValue="servers"
- variant="alt"
+ <Show when={shown()}>
+ <Suspense
+ fallback={
+ <div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
+ }
>
- <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
- <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
- {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
- {language.t("status.popover.tab.servers")}
- </Tabs.Trigger>
- <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
- {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
- {language.t("status.popover.tab.mcp")}
- </Tabs.Trigger>
- <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
- {lspCount() > 0 ? `${lspCount()} ` : ""}
- {language.t("status.popover.tab.lsp")}
- </Tabs.Trigger>
- <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
- {pluginCount() > 0 ? `${pluginCount()} ` : ""}
- {language.t("status.popover.tab.plugins")}
- </Tabs.Trigger>
- </Tabs.List>
-
- <Tabs.Content value="servers">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <For each={sortedServers()}>
- {(s) => {
- const key = ServerConnection.key(s)
- const isBlocked = () => health[key]?.healthy === false
- return (
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
- classList={{
- "hover:bg-surface-raised-base-hover": !isBlocked(),
- "cursor-not-allowed": isBlocked(),
- }}
- aria-disabled={isBlocked()}
- onClick={() => {
- if (isBlocked()) return
- navigate("/")
- queueMicrotask(() => server.setActive(key))
- }}
- >
- <ServerHealthIndicator health={health[key]} />
- <ServerRow
- conn={s}
- dimmed={isBlocked()}
- status={health[key]}
- class="flex items-center gap-2 w-full min-w-0"
- nameClass="text-14-regular text-text-base truncate"
- versionClass="text-12-regular text-text-weak truncate"
- badge={
- <Show when={key === defaultServer.key()}>
- <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
- {language.t("common.default")}
- </span>
- </Show>
- }
- >
- <div class="flex-1" />
- <Show when={server.current && key === ServerConnection.key(server.current)}>
- <Icon name="check" size="small" class="text-icon-weak shrink-0" />
- </Show>
- </ServerRow>
- </button>
- )
- }}
- </For>
-
- <Button
- variant="secondary"
- class="mt-3 self-start h-8 px-3 py-1.5"
- onClick={() => {
- const run = ++dialogRun
- void import("./dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
- })
- }}
- >
- {language.t("status.popover.action.manageServers")}
- </Button>
- </div>
- </div>
- </Tabs.Content>
-
- <Tabs.Content value="mcp">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <Show
- when={mcpNames().length > 0}
- fallback={
- <div class="text-14-regular text-text-base text-center my-auto">
- {language.t("dialog.mcp.empty")}
- </div>
- }
- >
- <For each={mcpNames()}>
- {(name) => {
- const status = () => mcpStatus(name)
- const enabled = () => status() === "connected"
- return (
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
- onClick={() => {
- if (toggleMcp.isPending) return
- toggleMcp.mutate(name)
- }}
- disabled={toggleMcp.isPending && toggleMcp.variables === name}
- >
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": status() === "connected",
- "bg-icon-critical-base": status() === "failed",
- "bg-border-weak-base": status() === "disabled",
- "bg-icon-warning-base":
- status() === "needs_auth" || status() === "needs_client_registration",
- }}
- />
- <span class="text-14-regular text-text-base truncate flex-1">{name}</span>
- <div onClick={(event) => event.stopPropagation()}>
- <Switch
- checked={enabled()}
- disabled={toggleMcp.isPending && toggleMcp.variables === name}
- onChange={() => {
- if (toggleMcp.isPending) return
- toggleMcp.mutate(name)
- }}
- />
- </div>
- </button>
- )
- }}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
-
- <Tabs.Content value="lsp">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <Show
- when={lspItems().length > 0}
- fallback={
- <div class="text-14-regular text-text-base text-center my-auto">
- {language.t("dialog.lsp.empty")}
- </div>
- }
- >
- <For each={lspItems()}>
- {(item) => (
- <div class="flex items-center gap-2 w-full px-2 py-1">
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": item.status === "connected",
- "bg-icon-critical-base": item.status === "error",
- }}
- />
- <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
- </div>
- )}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
-
- <Tabs.Content value="plugins">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <Show
- when={plugins().length > 0}
- fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
- >
- <For each={plugins()}>
- {(plugin) => (
- <div class="flex items-center gap-2 w-full px-2 py-1">
- <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
- <span class="text-14-regular text-text-base truncate">{plugin}</span>
- </div>
- )}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
- </Tabs>
- </div>
+ <Body shown={shown} />
+ </Suspense>
+ </Show>
</Popover>
)
}