Kaynağa Gözat

feat(app): change server

Adam 1 ay önce
ebeveyn
işleme
e0e07c5d48

+ 55 - 38
packages/app/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { ErrorBoundary, Show } from "solid-js"
+import { ErrorBoundary, Show, type ParentProps } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
@@ -12,6 +12,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { LayoutProvider } from "@/context/layout"
 import { GlobalSDKProvider } from "@/context/global-sdk"
+import { ServerProvider, useServer } from "@/context/server"
 import { TerminalProvider } from "@/context/terminal"
 import { PromptProvider } from "@/context/prompt"
 import { NotificationProvider } from "@/context/notification"
@@ -30,18 +31,30 @@ declare global {
   }
 }
 
-const url = iife(() => {
+const serverDefaults = iife(() => {
   const param = new URLSearchParams(document.location.search).get("url")
-  if (param) return param
+  if (param) return { url: param, forced: true }
 
-  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
-  if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+  if (location.hostname.includes("opencode.ai")) return { url: "http://localhost:4096", forced: false }
+  if (window.__OPENCODE__) return { url: `http://127.0.0.1:${window.__OPENCODE__.port}`, forced: false }
   if (import.meta.env.DEV)
-    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+    return {
+      url: `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`,
+      forced: false,
+    }
 
-  return window.location.origin
+  return { url: window.location.origin, forced: false }
 })
 
+function ServerKey(props: ParentProps) {
+  const server = useServer()
+  return (
+    <Show when={server.url} keyed>
+      {props.children}
+    </Show>
+  )
+}
+
 export function App() {
   return (
     <MetaProvider>
@@ -52,38 +65,42 @@ export function App() {
             <MarkedProvider>
               <DiffComponentProvider component={Diff}>
                 <CodeComponentProvider component={Code}>
-                  <GlobalSDKProvider url={url}>
-                    <GlobalSyncProvider>
-                      <LayoutProvider>
-                        <NotificationProvider>
-                          <Router
-                            root={(props) => (
-                              <CommandProvider>
-                                <Layout>{props.children}</Layout>
-                              </CommandProvider>
-                            )}
-                          >
-                            <Route path="/" component={Home} />
-                            <Route path="/:dir" component={DirectoryLayout}>
-                              <Route path="/" component={() => <Navigate href="session" />} />
-                              <Route
-                                path="/session/:id?"
-                                component={(p) => (
-                                  <Show when={p.params.id ?? "new"} keyed>
-                                    <TerminalProvider>
-                                      <PromptProvider>
-                                        <Session />
-                                      </PromptProvider>
-                                    </TerminalProvider>
-                                  </Show>
+                  <ServerProvider defaultUrl={serverDefaults.url} forceUrl={serverDefaults.forced}>
+                    <ServerKey>
+                      <GlobalSDKProvider>
+                        <GlobalSyncProvider>
+                          <LayoutProvider>
+                            <NotificationProvider>
+                              <Router
+                                root={(props) => (
+                                  <CommandProvider>
+                                    <Layout>{props.children}</Layout>
+                                  </CommandProvider>
                                 )}
-                              />
-                            </Route>
-                          </Router>
-                        </NotificationProvider>
-                      </LayoutProvider>
-                    </GlobalSyncProvider>
-                  </GlobalSDKProvider>
+                              >
+                                <Route path="/" component={Home} />
+                                <Route path="/:dir" component={DirectoryLayout}>
+                                  <Route path="/" component={() => <Navigate href="session" />} />
+                                  <Route
+                                    path="/session/:id?"
+                                    component={(p) => (
+                                      <Show when={p.params.id ?? "new"} keyed>
+                                        <TerminalProvider>
+                                          <PromptProvider>
+                                            <Session />
+                                          </PromptProvider>
+                                        </TerminalProvider>
+                                      </Show>
+                                    )}
+                                  />
+                                </Route>
+                              </Router>
+                            </NotificationProvider>
+                          </LayoutProvider>
+                        </GlobalSyncProvider>
+                      </GlobalSDKProvider>
+                    </ServerKey>
+                  </ServerProvider>
                 </CodeComponentProvider>
               </DiffComponentProvider>
             </MarkedProvider>

+ 170 - 0
packages/app/src/components/dialog-select-server.tsx

@@ -0,0 +1,170 @@
+import { createEffect, createMemo, onCleanup } 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 { useServer } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { useNavigate } from "@solidjs/router"
+
+type ServerStatus = { healthy: boolean; version?: string }
+
+function displayName(url: string) {
+  return url
+    .replace(/^https?:\/\//, "")
+    .replace(/\/+$/, "")
+    .split("/")[0]
+}
+
+function normalize(input: string) {
+  const trimmed = input.trim()
+  if (!trimmed) return
+  const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+  const cleaned = withProtocol.replace(/\/+$/, "")
+  return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
+}
+
+async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
+  const sdk = createOpencodeClient({
+    baseUrl: url,
+    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 DialogSelectServer() {
+  const navigate = useNavigate()
+  const dialog = useDialog()
+  const server = useServer()
+  const platform = usePlatform()
+  const [store, setStore] = createStore({
+    url: "",
+    adding: false,
+    error: "",
+    status: {} as Record<string, ServerStatus | undefined>,
+  })
+
+  const items = 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 current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
+
+  async function refreshHealth() {
+    const results: Record<string, ServerStatus> = {}
+    await Promise.all(
+      items().map(async (url) => {
+        results[url] = await checkHealth(url, platform.fetch)
+      }),
+    )
+    setStore("status", reconcile(results))
+  }
+
+  createEffect(() => {
+    items()
+    refreshHealth()
+    const interval = setInterval(refreshHealth, 10_000)
+    onCleanup(() => clearInterval(interval))
+  })
+
+  function select(value: string) {
+    if (store.status[value]?.healthy === false) return
+    dialog.close()
+    server.setActive(value)
+    navigate("/")
+  }
+
+  async function handleSubmit(e: SubmitEvent) {
+    e.preventDefault()
+    const value = normalize(store.url)
+    if (!value) return
+
+    setStore("adding", true)
+    setStore("error", "")
+
+    const result = await checkHealth(value, platform.fetch)
+    setStore("adding", false)
+
+    if (!result.healthy) {
+      setStore("error", "Could not connect to server")
+      return
+    }
+
+    setStore("url", "")
+    select(value)
+  }
+
+  return (
+    <Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
+      <div class="flex flex-col gap-4 pb-4">
+        <List
+          search={{ placeholder: "Search servers", autofocus: true }}
+          emptyMessage="No servers yet"
+          items={items}
+          key={(x) => x}
+          current={current()}
+          onSelect={(x) => {
+            if (x) select(x)
+          }}
+        >
+          {(i) => (
+            <div
+              class="flex items-center gap-2 min-w-0 flex-1"
+              classList={{ "opacity-50": store.status[i]?.healthy === false }}
+            >
+              <div
+                classList={{
+                  "size-1.5 rounded-full shrink-0": true,
+                  "bg-icon-success-base": store.status[i]?.healthy === true,
+                  "bg-icon-critical-base": store.status[i]?.healthy === false,
+                  "bg-border-weak-base": store.status[i] === undefined,
+                }}
+              />
+              <span class="truncate">{displayName(i)}</span>
+              <span class="text-text-weak">{store.status[i]?.version}</span>
+            </div>
+          )}
+        </List>
+
+        <div class="mt-6 px-3 flex flex-col gap-1.5">
+          <div class="px-3">
+            <h3 class="text-14-regular text-text-weak">Add a server</h3>
+          </div>
+          <form onSubmit={handleSubmit}>
+            <div class="flex items-start gap-2">
+              <div class="flex-1 min-w-0 h-auto">
+                <TextField
+                  type="text"
+                  label="Server URL"
+                  hideLabel
+                  placeholder="http://localhost:4096"
+                  value={store.url}
+                  onChange={(v) => {
+                    setStore("url", v)
+                    setStore("error", "")
+                  }}
+                  validationState={store.error ? "invalid" : "valid"}
+                  error={store.error}
+                />
+              </div>
+              <Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
+                {store.adding ? "Checking..." : "Add"}
+              </Button>
+            </div>
+          </form>
+        </div>
+      </div>
+    </Dialog>
+  )
+}

+ 4 - 6
packages/app/src/components/session-lsp-indicator.tsx

@@ -1,5 +1,4 @@
 import { createMemo, Show } from "solid-js"
-import { Icon } from "@opencode-ai/ui/icon"
 import { useSync } from "@/context/sync"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 
@@ -24,12 +23,11 @@ export function SessionLspIndicator() {
     <Show when={lspStats().total > 0}>
       <Tooltip placement="top" value={tooltipContent()}>
         <div class="flex items-center gap-1 px-2 cursor-default select-none">
-          <Icon
-            name="code"
-            size="small"
+          <div
             classList={{
-              "text-icon-critical-base": lspStats().hasError,
-              "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
+              "size-1.5 rounded-full": true,
+              "bg-icon-critical-base": lspStats().hasError,
+              "bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
             }}
           />
           <span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>

+ 4 - 6
packages/app/src/components/session-mcp-indicator.tsx

@@ -1,6 +1,5 @@
 import { createMemo, Show } from "solid-js"
 import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useSync } from "@/context/sync"
 import { DialogSelectMcp } from "@/components/dialog-select-mcp"
@@ -21,12 +20,11 @@ export function SessionMcpIndicator() {
   return (
     <Show when={mcpStats().total > 0}>
       <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
-        <Icon
-          name="mcp"
-          size="small"
+        <div
           classList={{
-            "text-icon-critical-base": mcpStats().failed,
-            "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
+            "size-1.5 rounded-full": true,
+            "bg-icon-critical-base": mcpStats().failed,
+            "bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
           }}
         />
         <span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>

+ 24 - 5
packages/app/src/components/status-bar.tsx

@@ -1,10 +1,14 @@
 import { createMemo, Show, type ParentProps } from "solid-js"
-import { usePlatform } from "@/context/platform"
 import { useSync } from "@/context/sync"
 import { useGlobalSync } from "@/context/global-sync"
+import { useServer } from "@/context/server"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Button } from "@opencode-ai/ui/button"
+import { DialogSelectServer } from "@/components/dialog-select-server"
 
 export function StatusBar(props: ParentProps) {
-  const platform = usePlatform()
+  const dialog = useDialog()
+  const server = useServer()
   const sync = useSync()
   const globalSync = useGlobalSync()
 
@@ -19,9 +23,24 @@ export function StatusBar(props: ParentProps) {
   return (
     <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
       <div class="flex items-center gap-3">
-        <Show when={platform.version}>
-          <span class="text-12-regular text-text-weak">v{platform.version}</span>
-        </Show>
+        <div class="flex items-center gap-1">
+          <Button
+            size="small"
+            variant="ghost"
+            onClick={() => {
+              dialog.show(() => <DialogSelectServer />)
+            }}
+          >
+            <div
+              classList={{
+                "size-1.5 rounded-full": true,
+                "bg-icon-success-base": server.healthy(),
+                "bg-icon-critical-base": !server.healthy(),
+              }}
+            />
+            <span class="text-12-regular text-text-weak">{server.name}</span>
+          </Button>
+        </div>
         <Show when={directoryDisplay()}>
           <span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
         </Show>

+ 15 - 8
packages/app/src/context/global-sdk.tsx

@@ -1,34 +1,41 @@
 import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
 import { usePlatform } from "./platform"
+import { useServer } from "./server"
 
 export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
   name: "GlobalSDK",
-  init: (props: { url: string }) => {
+  init: () => {
+    const server = useServer()
+    const abort = new AbortController()
+
     const eventSdk = createOpencodeClient({
-      baseUrl: props.url,
-      // signal: AbortSignal.timeout(1000 * 60 * 10),
+      baseUrl: server.url,
+      signal: abort.signal,
     })
     const emitter = createGlobalEmitter<{
       [key: string]: Event
     }>()
 
-    eventSdk.global.event().then(async (events) => {
+    void (async () => {
+      const events = await eventSdk.global.event()
       for await (const event of events.stream) {
-        // console.log("event", event)
         emitter.emit(event.directory ?? "global", event.payload)
       }
-    })
+    })().catch(() => undefined)
+
+    onCleanup(() => abort.abort())
 
     const platform = usePlatform()
     const sdk = createOpencodeClient({
-      baseUrl: props.url,
+      baseUrl: server.url,
       signal: AbortSignal.timeout(1000 * 60 * 10),
       fetch: platform.fetch,
       throwOnError: true,
     })
 
-    return { url: props.url, client: sdk, event: emitter }
+    return { url: server.url, client: sdk, event: emitter }
   },
 })

+ 11 - 19
packages/app/src/context/layout.tsx

@@ -3,6 +3,7 @@ import { batch, createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
+import { useServer } from "./server"
 import { Project } from "@opencode-ai/sdk/v2"
 import { persisted } from "@/utils/persist"
 
@@ -34,10 +35,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
   init: () => {
     const globalSdk = useGlobalSDK()
     const globalSync = useGlobalSync()
+    const server = useServer()
     const [store, setStore, _, ready] = persisted(
-      "layout.v3",
+      "layout.v4",
       createStore({
-        projects: [] as { worktree: string; expanded: boolean }[],
         sidebar: {
           opened: false,
           width: 280,
@@ -86,12 +87,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       return project
     }
 
-    const enriched = createMemo(() => store.projects.flatMap(enrich))
+    const enriched = createMemo(() => server.projects.list().flatMap(enrich))
     const list = createMemo(() => enriched().flatMap(colorize))
 
     onMount(() => {
       Promise.all(
-        store.projects.map((project) => {
+        server.projects.list().map((project) => {
           return globalSync.project.loadSessions(project.worktree)
         }),
       )
@@ -102,32 +103,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       projects: {
         list,
         open(directory: string) {
-          if (store.projects.find((x) => x.worktree === directory)) {
+          if (server.projects.list().find((x) => x.worktree === directory)) {
             return
           }
           globalSync.project.loadSessions(directory)
-          setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
+          server.projects.open(directory)
         },
         close(directory: string) {
-          setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
+          server.projects.close(directory)
         },
         expand(directory: string) {
-          const index = store.projects.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", index, "expanded", true)
+          server.projects.expand(directory)
         },
         collapse(directory: string) {
-          const index = store.projects.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", index, "expanded", false)
+          server.projects.collapse(directory)
         },
         move(directory: string, toIndex: number) {
-          setStore("projects", (projects) => {
-            const fromIndex = projects.findIndex((x) => x.worktree === directory)
-            if (fromIndex === -1 || fromIndex === toIndex) return projects
-            const result = [...projects]
-            const [item] = result.splice(fromIndex, 1)
-            result.splice(toIndex, 0, item)
-            return result
-          })
+          server.projects.move(directory, toIndex)
         },
       },
       sidebar: {

+ 186 - 0
packages/app/src/context/server.tsx

@@ -0,0 +1,186 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { usePlatform } from "@/context/platform"
+import { persisted } from "@/utils/persist"
+
+type StoredProject = { worktree: string; expanded: boolean }
+
+function normalize(input: string) {
+  const trimmed = input.trim()
+  if (!trimmed) return
+  const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+  const cleaned = withProtocol.replace(/\/+$/, "")
+  return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
+}
+
+function displayName(url: string) {
+  return url
+    .replace(/^https?:\/\//, "")
+    .replace(/\/+$/, "")
+    .split("/")[0]
+}
+
+export const { use: useServer, provider: ServerProvider } = createSimpleContext({
+  name: "Server",
+  init: (props: { defaultUrl: string; forceUrl?: boolean }) => {
+    const platform = usePlatform()
+    const fallback = () => normalize(props.defaultUrl)
+    const [forced, setForced] = createSignal(props.forceUrl ?? false)
+
+    const [store, setStore, _, ready] = persisted(
+      "server.v2",
+      createStore({
+        list: [] as string[],
+        active: "",
+        projects: {} as Record<string, StoredProject[]>,
+      }),
+    )
+
+    function setActive(input: string) {
+      const url = normalize(input)
+      if (!url) return
+      batch(() => {
+        if (!store.list.includes(url)) {
+          setStore("list", (list) => [url, ...list])
+        }
+        setStore("active", url)
+      })
+    }
+
+    function remove(input: string) {
+      const url = normalize(input)
+      if (!url) return
+
+      const list = store.list.filter((x) => x !== url)
+      const next = store.active === url ? (list[0] ?? fallback() ?? "") : store.active
+
+      batch(() => {
+        setStore("list", list)
+        setStore("active", next)
+      })
+    }
+
+    createEffect(() => {
+      if (!ready()) return
+
+      const url = fallback()
+      if (!url) return
+
+      if (forced()) {
+        batch(() => {
+          if (!store.list.includes(url)) {
+            setStore("list", (list) => [url, ...list])
+          }
+          if (store.active !== url) {
+            setStore("active", url)
+          }
+        })
+        setForced(false)
+        return
+      }
+
+      if (store.list.length === 0) {
+        batch(() => {
+          setStore("list", [url])
+          setStore("active", url)
+        })
+        return
+      }
+
+      if (store.active && store.list.includes(store.active)) return
+      setStore("active", store.list[0])
+    })
+
+    const isReady = createMemo(() => ready() && !!store.active)
+
+    const [healthy, { refetch }] = createResource(
+      () => store.active,
+      async (url) => {
+        if (!url) return true
+
+        const sdk = createOpencodeClient({
+          baseUrl: url,
+          fetch: platform.fetch,
+          signal: AbortSignal.timeout(2000),
+        })
+        return sdk.global
+          .health()
+          .then((x) => x.data?.healthy === true)
+          .catch(() => false)
+      },
+      { initialValue: true },
+    )
+
+    createEffect(() => {
+      if (!store.active) return
+      const interval = setInterval(() => refetch(), 10_000)
+      onCleanup(() => clearInterval(interval))
+    })
+
+    const projectsList = createMemo(() => store.projects[store.active] ?? [])
+
+    return {
+      ready: isReady,
+      healthy,
+      get url() {
+        return store.active
+      },
+      get name() {
+        return displayName(store.active)
+      },
+      get list() {
+        return store.list
+      },
+      setActive,
+      add: setActive,
+      remove,
+      projects: {
+        list: projectsList,
+        open(directory: string) {
+          const url = store.active
+          if (!url) return
+          const current = store.projects[url] ?? []
+          if (current.find((x) => x.worktree === directory)) return
+          setStore("projects", url, [{ worktree: directory, expanded: true }, ...current])
+        },
+        close(directory: string) {
+          const url = store.active
+          if (!url) return
+          const current = store.projects[url] ?? []
+          setStore(
+            "projects",
+            url,
+            current.filter((x) => x.worktree !== directory),
+          )
+        },
+        expand(directory: string) {
+          const url = store.active
+          if (!url) return
+          const current = store.projects[url] ?? []
+          const index = current.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", url, index, "expanded", true)
+        },
+        collapse(directory: string) {
+          const url = store.active
+          if (!url) return
+          const current = store.projects[url] ?? []
+          const index = current.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", url, index, "expanded", false)
+        },
+        move(directory: string, toIndex: number) {
+          const url = store.active
+          if (!url) return
+          const current = store.projects[url] ?? []
+          const fromIndex = current.findIndex((x) => x.worktree === directory)
+          if (fromIndex === -1 || fromIndex === toIndex) return
+          const result = [...current]
+          const [item] = result.splice(fromIndex, 1)
+          result.splice(toIndex, 0, item)
+          setStore("projects", url, result)
+        },
+      },
+    }
+  },
+})

+ 18 - 0
packages/app/src/pages/home.tsx

@@ -10,6 +10,8 @@ import { usePlatform } from "@/context/platform"
 import { DateTime } from "luxon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { useServer } from "@/context/server"
 
 export default function Home() {
   const sync = useGlobalSync()
@@ -17,6 +19,7 @@ export default function Home() {
   const platform = usePlatform()
   const dialog = useDialog()
   const navigate = useNavigate()
+  const server = useServer()
   const homedir = createMemo(() => sync.data.path.home)
 
   function openProject(directory: string) {
@@ -52,6 +55,21 @@ export default function Home() {
   return (
     <div class="mx-auto mt-55">
       <Logo class="w-xl opacity-12" />
+      <Button
+        size="large"
+        variant="ghost"
+        class="mt-4 mx-auto text-14-regular text-text-weak"
+        onClick={() => dialog.show(() => <DialogSelectServer />)}
+      >
+        <div
+          classList={{
+            "size-2 rounded-full": true,
+            "bg-icon-success-base": server.healthy(),
+            "bg-icon-critical-base": !server.healthy(),
+          }}
+        />
+        {server.name}
+      </Button>
       <Switch>
         <Match when={sync.data.project.length > 0}>
           <div class="mt-20 w-full flex flex-col gap-4">

+ 11 - 0
packages/app/src/pages/layout.tsx

@@ -50,6 +50,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { DialogEditProject } from "@/components/dialog-edit-project"
+import { DialogSelectServer } from "@/components/dialog-select-server"
 import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis } from "@/utils/solid-dnd"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
@@ -352,6 +353,12 @@ export default function Layout(props: ParentProps) {
         category: "Provider",
         onSelect: () => connectProvider(),
       },
+      {
+        id: "server.switch",
+        title: "Switch server",
+        category: "Server",
+        onSelect: () => openServer(),
+      },
       {
         id: "session.previous",
         title: "Previous session",
@@ -427,6 +434,10 @@ export default function Layout(props: ParentProps) {
     dialog.show(() => <DialogSelectProvider />)
   }
 
+  function openServer() {
+    dialog.show(() => <DialogSelectServer />)
+  }
+
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
     const lastSession = store.lastSession[directory]

+ 1 - 1
packages/ui/src/components/dialog.css

@@ -83,7 +83,7 @@
       [data-slot="dialog-description"] {
         display: flex;
         padding: 16px;
-        padding-left: 20px;
+        padding-left: 24px;
         padding-top: 0;
         margin-top: -8px;
         justify-content: space-between;

+ 19 - 3
packages/ui/src/components/list.css

@@ -53,6 +53,8 @@
     }
 
     > [data-component="icon-button"] {
+      width: 20px;
+      height: 20px;
       background-color: transparent;
 
       &:hover:not(:disabled),
@@ -185,11 +187,25 @@
           letter-spacing: var(--letter-spacing-normal);
 
           [data-slot="list-item-selected-icon"] {
-            color: var(--icon-strong-base);
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            flex-shrink: 0;
+            aspect-ratio: 1/1;
+            [data-component="icon"] {
+              color: var(--icon-strong-base);
+            }
           }
           [data-slot="list-item-active-icon"] {
+            display: inline-flex;
+            align-items: center;
+            justify-content: center;
+            flex-shrink: 0;
+            aspect-ratio: 1/1;
             display: none;
-            color: var(--icon-strong-base);
+            [data-component="icon"] {
+              color: var(--icon-strong-base);
+            }
           }
 
           [data-slot="list-item-extra-icon"] {
@@ -201,7 +217,7 @@
             border-radius: var(--radius-md);
             background: var(--surface-raised-base-hover);
             [data-slot="list-item-active-icon"] {
-              display: block;
+              display: inline-flex;
             }
             [data-slot="list-item-extra-icon"] {
               display: block !important;

+ 8 - 2
packages/ui/src/components/list.tsx

@@ -206,10 +206,16 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
                       >
                         {props.children(item)}
                         <Show when={item === props.current}>
-                          <Icon data-slot="list-item-selected-icon" name="check-small" />
+                          <span data-slot="list-item-selected-icon">
+                            <Icon name="check-small" />
+                          </span>
                         </Show>
                         <Show when={props.activeIcon}>
-                          {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+                          {(icon) => (
+                            <span data-slot="list-item-active-icon">
+                              <Icon name={icon()} />
+                            </span>
+                          )}
                         </Show>
                       </button>
                     )}

+ 0 - 4
packages/ui/src/context/dialog.tsx

@@ -1,6 +1,5 @@
 import {
   createContext,
-  createEffect,
   createSignal,
   getOwner,
   Owner,
@@ -70,9 +69,6 @@ function init() {
 
 export function DialogProvider(props: ParentProps) {
   const ctx = init()
-  createEffect(() => {
-    console.log("active", ctx.active)
-  })
   return (
     <Context.Provider value={ctx}>
       {props.children}