Просмотр исходного кода

feat(web): implement new server management for web and desktop (#8513)

OpeOginni 4 недель назад
Родитель
Сommit
67ea21b55a

+ 1 - 1
packages/app/src/app.tsx

@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
   )
 }
 
-export function AppInterface(props: { defaultUrl?: string }) {
+export function AppInterface(props: { defaultUrl?: string; }) {
   const defaultServerUrl = () => {
     if (props.defaultUrl) return props.defaultUrl
     if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"

+ 405 - 126
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<ServerStatus> {
+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<typeof usePlatform>): Promise<ServerStatus> {
   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 (
+    <div class="flex items-center gap-3 px-4 min-w-0 flex-1">
+      <div
+        classList={{
+          "size-1.5 rounded-full shrink-0": true,
+          "bg-icon-success-base": props.status === true,
+          "bg-icon-critical-base": props.status === false,
+          "bg-border-weak-base": props.status === undefined,
+        }}
+      />
+      <div class="flex-1 min-w-0">
+        <TextField
+          type="text"
+          hideLabel
+          placeholder={props.placeholder}
+          value={props.value}
+          autofocus
+          validationState={props.error ? "invalid" : "valid"}
+          error={props.error}
+          disabled={props.adding}
+          onChange={props.onChange}
+          onKeyDown={props.onKeyDown}
+          onBlur={props.onBlur}
+        />
+      </div>
+    </div>
+  )
+}
+
+function EditRow(props: EditRowProps) {
+  return (
+    <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
+      <div
+        classList={{
+          "size-1.5 rounded-full shrink-0": true,
+          "bg-icon-success-base": props.status === true,
+          "bg-icon-critical-base": props.status === false,
+          "bg-border-weak-base": props.status === undefined,
+        }}
+      />
+      <div class="flex-1 min-w-0">
+        <TextField
+          type="text"
+          hideLabel
+          placeholder={props.placeholder}
+          value={props.value}
+          autofocus
+          validationState={props.error ? "invalid" : "valid"}
+          error={props.error}
+          disabled={props.busy}
+          onChange={props.onChange}
+          onKeyDown={props.onKeyDown}
+          onBlur={props.onBlur}
+        />
+      </div>
+    </div>
+  )
+}
+
 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<string, ServerStatus | undefined>,
+    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<string, ServerStatus> = {}
     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<HTMLDivElement>('[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 (
-    <Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}>
-      <div class="flex flex-col gap-4 pb-4">
+    <Dialog title={language.t("dialog.server.title")}>
+      <div class="flex flex-col gap-2 pb-4">
         <List
           search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }}
           emptyMessage={language.t("dialog.server.empty")}
           items={sortedItems}
           key={(x) => 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: () => (
+                    <AddRow
+                      value={store.addServer.url}
+                      placeholder={language.t("dialog.server.add.placeholder")}
+                      adding={store.addServer.adding}
+                      error={store.addServer.error}
+                      status={store.addServer.status}
+                      onChange={handleAddChange}
+                      onKeyDown={handleAddKey}
+                      onBlur={blurAdd}
+                    />
+                  ),
+                }
+              : undefined
+          }
         >
-          {(i) => (
-            <div class="flex items-center gap-2 min-w-0 flex-1 group/item">
-              <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">{serverDisplayName(i)}</span>
-                <span class="text-text-weak">{store.status[i]?.version}</span>
+          {(i) => {
+            const [popoverOpen, setPopoverOpen] = createSignal(false)
+            return (
+              <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
+                <Show
+                  when={store.editServer.id !== i}
+                  fallback={
+                    <EditRow
+                      value={store.editServer.value}
+                      placeholder={language.t("dialog.server.add.placeholder")}
+                      busy={store.editServer.busy}
+                      error={store.editServer.error}
+                      status={store.editServer.status}
+                      onChange={handleEditChange}
+                      onKeyDown={(event) => handleEditKey(event, i)}
+                      onBlur={() => handleEdit(i, store.editServer.value)}
+                    />
+                  }
+                >
+                  <div
+                    class="flex items-center gap-3 px-4 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">{serverDisplayName(i)}</span>
+                    <Show when={store.status[i]?.version}>
+                      <span class="text-text-weak text-14-regular">{store.status[i]?.version}</span>
+                    </Show>
+                    <Show when={defaultUrl() === i}>
+                      <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
+                        {language.t("dialog.server.status.default")}
+                      </span>
+                    </Show>
+                  </div>
+                </Show>
+                <Show when={store.editServer.id !== i}>
+                  <div class="flex items-center justify-center gap-5 px-4">
+                    <Show when={current() === i}>
+                      <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
+                    </Show>
+
+                    <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
+                      <Popover
+                        open={popoverOpen()}
+                        onOpenChange={setPopoverOpen}
+                        placement="bottom-start"
+                        trigger={
+                          <IconButton
+                            icon="dot-grid"
+                            variant="ghost"
+                            class="bg-transparent transition-opacity shrink-0 hover:scale-110 size-8"
+                            onPointerDown={(event: PointerEvent) => event.stopPropagation()}
+                          />
+                        }
+                        class="w-max !min-w-fit !max-w-none"
+                      >
+                        <div class="flex flex-col gap-1">
+                          <Button
+                            variant="ghost"
+                            size="normal"
+                            class="justify-start text-md"
+                            onClick={(e: MouseEvent) => {
+                              e.stopPropagation()
+                              setPopoverOpen(false)
+                              setStore("editServer", {
+                                id: i,
+                                value: i,
+                                error: "",
+                                status: store.status[i]?.healthy,
+                              })
+                            }}
+                          >
+                            {language.t("dialog.server.menu.edit")}
+                          </Button>
+                          <Show when={isDesktop && defaultUrl() !== i}>
+                            <Button
+                              variant="ghost"
+                              size="normal"
+                              class="justify-start text-md"
+                              onClick={async (e: MouseEvent) => {
+                                e.stopPropagation()
+                                setPopoverOpen(false)
+                                await platform.setDefaultServerUrl?.(i)
+                                defaultUrlActions.refetch(i)
+                              }}
+                            >
+                              {language.t("dialog.server.menu.default")}
+                            </Button>
+                          </Show>
+                          <Show when={isDesktop && defaultUrl() === i}>
+                            <Button
+                              variant="ghost"
+                              size="normal"
+                              class="justify-start text-md"
+                              onClick={async (e: MouseEvent) => {
+                                e.stopPropagation()
+                                setPopoverOpen(false)
+                                await platform.setDefaultServerUrl?.(null)
+                                defaultUrlActions.refetch(null)
+                              }}
+                            >
+                              {language.t("dialog.server.menu.defaultRemove")}
+                            </Button>
+                          </Show>
+                          <div class="h-px bg-border-weak-base my-1" />
+                          <Button
+                            variant="ghost"
+                            size="normal"
+                            class="justify-start text-md text-text-on-critical-base hover:bg-surface-critical-weak"
+                            onClick={(e: MouseEvent) => {
+                              e.stopPropagation()
+                              setPopoverOpen(false)
+                              handleRemove(i)
+                            }}
+                          >
+                            {language.t("dialog.server.menu.delete")}
+                          </Button>
+                        </div>
+                      </Popover>
+                    </div>
+                  </div>
+                </Show>
               </div>
-              <Show when={current() !== i && server.list.includes(i)}>
-                <IconButton
-                  icon="circle-x"
-                  variant="ghost"
-                  class="bg-transparent transition-opacity shrink-0 hover:scale-110"
-                  aria-label={language.t("dialog.server.action.remove")}
-                  onClick={(e) => {
-                    e.stopPropagation()
-                    handleRemove(i)
-                  }}
-                />
-              </Show>
-            </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">{language.t("dialog.server.add.title")}</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={language.t("dialog.server.add.url")}
-                  hideLabel
-                  placeholder={language.t("dialog.server.add.placeholder")}
-                  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 ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
-              </Button>
-            </div>
-          </form>
+        <div class="px-6">
+          <Button
+            variant="secondary"
+            icon="plus-small"
+            size="large"
+            onClick={() => {
+              setStore("addServer", { showForm: true, url: "", error: "" })
+              scrollListToBottom()
+            }}
+            class="px-3 py-4"
+          >
+            {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
+          </Button>
         </div>
-
-        <Show when={isDesktop}>
-          <div class="mt-6 px-3 flex flex-col gap-1.5">
-            <div class="px-3">
-              <h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3>
-              <p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p>
-            </div>
-            <div class="flex items-center gap-2 px-3 py-2">
-              <Show
-                when={defaultUrl()}
-                fallback={
-                  <Show
-                    when={server.url}
-                    fallback={
-                      <span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span>
-                    }
-                  >
-                    <Button
-                      variant="secondary"
-                      size="small"
-                      onClick={async () => {
-                        await platform.setDefaultServerUrl?.(server.url)
-                        defaultUrlActions.refetch(server.url)
-                      }}
-                    >
-                      {language.t("dialog.server.default.set")}
-                    </Button>
-                  </Show>
-                }
-              >
-                <div class="flex items-center gap-2 flex-1 min-w-0">
-                  <span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
-                </div>
-                <Button
-                  variant="ghost"
-                  size="small"
-                  onClick={async () => {
-                    await platform.setDefaultServerUrl?.(null)
-                    defaultUrlActions.refetch()
-                  }}
-                >
-                  {language.t("dialog.server.default.clear")}
-                </Button>
-              </Show>
-            </div>
-          </div>
-        </Show>
       </div>
     </Dialog>
   )

+ 0 - 42
packages/app/src/components/session-lsp-indicator.tsx

@@ -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 (
-    <Show when={lspStats().total > 0}>
-      <Tooltip placement="top" value={tooltipContent()}>
-        <div class="flex items-center gap-1 px-2 cursor-default select-none">
-          <div
-            classList={{
-              "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">
-            {language.t("lsp.label.connected", { count: lspStats().connected })}
-          </span>
-        </div>
-      </Tooltip>
-    </Show>
-  )
-}

+ 0 - 34
packages/app/src/components/session-mcp-indicator.tsx

@@ -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 (
-    <Show when={mcpStats().total > 0}>
-      <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
-        <div
-          classList={{
-            "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>
-      </Button>
-    </Show>
-  )
-}

+ 69 - 93
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) => (
           <Portal mount={mount()}>
             <div class="flex items-center gap-3">
-              {/* <div class="hidden md: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() === true, */}
-              {/*         "bg-icon-critical-base": server.healthy() === false, */}
-              {/*         "bg-border-weak-base": server.healthy() === undefined, */}
-              {/*       }} */}
-              {/*     /> */}
-              {/*     <Icon name="server" size="small" class="text-icon-weak" /> */}
-              {/*     <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
-              {/*   </Button> */}
-              {/*   <SessionLspIndicator /> */}
-              {/*   <SessionMcpIndicator /> */}
-              {/* </div> */}
-              <div class="flex items-center gap-1">
-                <div class="hidden md:block shrink-0">
-                  <TooltipKeybind
-                    title={language.t("command.review.toggle")}
-                    keybind={command.keybind("review.toggle")}
-                  >
-                    <Button
-                      variant="ghost"
-                      class="group/review-toggle size-6 p-0"
-                      onClick={() => view().reviewPanel.toggle()}
-                      aria-label={language.t("command.review.toggle")}
-                      aria-expanded={view().reviewPanel.opened()}
-                      aria-controls="review-panel"
-                      tabIndex={showReview() ? 0 : -1}
-                    >
-                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                        <Icon
-                          size="small"
-                          name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
-                          class="group-hover/review-toggle:hidden"
-                        />
-                        <Icon
-                          size="small"
-                          name="layout-right-partial"
-                          class="hidden group-hover/review-toggle:inline-block"
-                        />
-                        <Icon
-                          size="small"
-                          name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
-                          class="hidden group-active/review-toggle:inline-block"
-                        />
-                      </div>
-                    </Button>
-                  </TooltipKeybind>
-                </div>
-                <TooltipKeybind
-                  class="hidden md:block shrink-0"
-                  title={language.t("command.terminal.toggle")}
-                  keybind={command.keybind("terminal.toggle")}
-                >
-                  <Button
-                    variant="ghost"
-                    class="group/terminal-toggle size-6 p-0"
-                    onClick={() => view().terminal.toggle()}
-                    aria-label={language.t("command.terminal.toggle")}
-                    aria-expanded={view().terminal.opened()}
-                    aria-controls="terminal-panel"
-                  >
-                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                      <Icon
-                        size="small"
-                        name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                        class="group-hover/terminal-toggle:hidden"
-                      />
-                      <Icon
-                        size="small"
-                        name="layout-bottom-partial"
-                        class="hidden group-hover/terminal-toggle:inline-block"
-                      />
-                      <Icon
-                        size="small"
-                        name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                        class="hidden group-active/terminal-toggle:inline-block"
-                      />
-                    </div>
-                  </Button>
-                </TooltipKeybind>
-              </div>
+              <StatusPopover />
               <Show when={showShare()}>
                 <div class="flex items-center">
                   <Popover
@@ -253,9 +163,11 @@ export function SessionHeader() {
                         ? language.t("session.share.popover.description.shared")
                         : language.t("session.share.popover.description.unshared")
                     }
+                    gutter={8}
                     triggerAs={Button}
                     triggerProps={{
                       variant: "secondary",
+                      class: "rounded-sm w-[60px] h-[24px]",
                       classList: { "rounded-r-none": shareUrl() !== undefined },
                       style: { scale: 1 },
                     }}
@@ -308,7 +220,7 @@ export function SessionHeader() {
                       </Show>
                     </div>
                   </Popover>
-                  <Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
+                  <Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
                     <Tooltip
                       value={
                         state.copied
@@ -334,6 +246,70 @@ export function SessionHeader() {
                   </Show>
                 </div>
               </Show>
+              <div class="hidden md:block shrink-0">
+                <TooltipKeybind
+                  title={language.t("command.terminal.toggle")}
+                  keybind={command.keybind("terminal.toggle")}
+                >
+                  <Button
+                    variant="ghost"
+                    class="group/terminal-toggle size-5 p-0"
+                    onClick={() => view().terminal.toggle()}
+                    aria-label={language.t("command.terminal.toggle")}
+                    aria-expanded={view().terminal.opened()}
+                    aria-controls="terminal-panel"
+                  >
+                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+                        class="group-hover/terminal-toggle:hidden"
+                      />
+                      <Icon
+                        size="small"
+                        name="layout-bottom-partial"
+                        class="hidden group-hover/terminal-toggle:inline-block"
+                      />
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                        class="hidden group-active/terminal-toggle:inline-block"
+                      />
+                    </div>
+                  </Button>
+                </TooltipKeybind>
+              </div>
+              <div class="hidden md:block shrink-0">
+                <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
+                  <Button
+                    variant="ghost"
+                    class="group/review-toggle size-5 p-0"
+                    onClick={() => view().reviewPanel.toggle()}
+                    aria-label={language.t("command.review.toggle")}
+                    aria-expanded={view().reviewPanel.opened()}
+                    aria-controls="review-panel"
+                    tabIndex={showReview() ? 0 : -1}
+                  >
+                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                      <Icon
+                        size="small"
+                        name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
+                        class="group-hover/review-toggle:hidden"
+                      />
+                      <Icon
+                        size="small"
+                        name="layout-right-partial"
+                        class="hidden group-hover/review-toggle:inline-block"
+                      />
+                      <Icon
+                        size="small"
+                        name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
+                        class="hidden group-active/review-toggle:inline-block"
+                      />
+                    </div>
+                  </Button>
+                </TooltipKeybind>
+              </div>
             </div>
           </Portal>
         )}

+ 364 - 0
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<typeof usePlatform>): Promise<ServerStatus> {
+  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<string | null>(null)
+  const [store, setStore] = createStore({
+    status: {} as Record<string, ServerStatus | undefined>,
+  })
+
+  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<string, ServerStatus> = {}
+    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<string | undefined>()
+
+  createEffect(() => {
+    const result = platform.getDefaultServerUrl?.()
+    if (result instanceof Promise) {
+      result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined))
+      return
+    }
+    if (result) setDefaultServerUrl(normalizeServerUrl(result))
+  })
+
+  return (
+    <Popover
+      triggerAs={Button}
+      triggerProps={{
+        variant: "ghost",
+        class: "rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none",
+        style: { scale: 1 },
+      }}
+      trigger={
+        <div class="flex items-center gap-1.5">
+          <div
+            classList={{
+              "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,
+            }}
+          />
+          <span class="text-12-regular text-text-strong">Status</span>
+        </div>
+      }
+      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}
+    >
+      <div class="flex items-center gap-1 w-[360px] border border-border-weak-base rounded-xl">
+        <Tabs
+          aria-label="Server Configurations"
+          class="tabs"
+          data-component="tabs"
+          data-active="servers"
+          defaultValue="servers"
+          variant="alt"
+          style={{
+            "background-color": "var(--background-strong)",
+            "border-radius": "12px",
+            overflow: "hidden",
+          }}
+        >
+          <Tabs.List
+            data-slot="tablist"
+            style={{
+              "background-color": "transparent",
+              "border-bottom": "none",
+              padding: "8px 16px 0",
+              gap: "16px",
+              height: "40px",
+            }}
+          >
+            <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
+              {serverCount() > 0 ? `${serverCount()} ` : ""}Servers
+            </Tabs.Trigger>
+            <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
+              {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP
+            </Tabs.Trigger>
+            <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
+              {lspCount() > 0 ? `${lspCount()} ` : ""}LSP
+            </Tabs.Trigger>
+            <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
+              {pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins
+            </Tabs.Trigger>
+          </Tabs.List>
+
+          <Tabs.Content value="servers">
+            <div class="flex flex-col px-2 pb-2">
+              <div class="flex flex-col p-2 bg-background-base">
+                <For each={sortedServers()}>
+                  {(url) => {
+                    const isActive = () => url === server.url
+                    const isDefault = () => url === defaultServerUrl()
+                    const status = () => store.status[url]
+                    const isBlocked = () => status()?.healthy === false
+                    return (
+                      <button
+                        type="button"
+                        class="flex items-center gap-2 w-full px-2 py-1 rounded-md transition-colors text-left"
+                        classList={{
+                          "opacity-50": isBlocked(),
+                          "hover:bg-surface-raised-base-hover": !isBlocked(),
+                          "cursor-not-allowed": isBlocked(),
+                        }}
+                        aria-disabled={isBlocked()}
+                        onClick={() => {
+                          if (isBlocked()) return
+                          server.setActive(url)
+                          navigate("/")
+                        }}
+                      >
+                        <div
+                          classList={{
+                            "size-1.5 rounded-full shrink-0": true,
+                            "bg-icon-success-base": status()?.healthy === true,
+                            "bg-icon-critical-base": status()?.healthy === false,
+                            "bg-border-weak-base": status() === undefined,
+                          }}
+                        />
+                        <span class="text-14-regular text-text-base truncate">{serverDisplayName(url)}</span>
+                        <Show when={status()?.version}>
+                          <span class="text-12-regular text-text-weak">{status()?.version}</span>
+                        </Show>
+                        <Show when={isDefault()}>
+                          <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+                            Default
+                          </span>
+                        </Show>
+                        <div class="flex-1" />
+                        <Show when={isActive()}>
+                          <Icon name="check" size="small" class="text-icon-weak shrink-0" />
+                        </Show>
+                      </button>
+                    )
+                  }}
+                </For>
+
+                <Button
+                  variant="secondary"
+                  class="mt-2 self-start"
+                  onClick={() => dialog.show(() => <DialogSelectServer />)}
+                >
+                  Manage servers
+                </Button>
+              </div>
+            </div>
+          </Tabs.Content>
+
+          <Tabs.Content value="mcp">
+            <div class="flex flex-col px-2 pb-2">
+              <div class="flex flex-col p-2 bg-background-base">
+                <Show
+                  when={mcpItems().length > 0}
+                  fallback={
+                    <div class="text-14-regular text-text-weak text-center py-4">No MCP servers configured</div>
+                  }
+                >
+                  <For each={mcpItems()}>
+                    {(item) => {
+                      const enabled = () => item.status === "connected"
+                      return (
+                        <button
+                          type="button"
+                          class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
+                          onClick={() => toggleMcp(item.name)}
+                          disabled={loading() === item.name}
+                        >
+                          <div
+                            classList={{
+                              "size-1.5 rounded-full shrink-0": true,
+                              "bg-icon-success-base": item.status === "connected",
+                              "bg-icon-critical-base": item.status === "failed",
+                              "bg-border-weak-base": item.status === "disabled",
+                              "bg-icon-warning-base":
+                                item.status === "needs_auth" || item.status === "needs_client_registration",
+                            }}
+                          />
+                          <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
+                          <div onClick={(event) => event.stopPropagation()}>
+                            <Switch
+                              checked={enabled()}
+                              disabled={loading() === item.name}
+                              onChange={() => toggleMcp(item.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-2 bg-background-base">
+                <Show
+                  when={lspItems().length > 0}
+                  fallback={
+                    <div class="text-14-regular text-text-weak text-center py-4">
+                      LSPs auto-detected from file types
+                    </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-2 bg-background-base">
+                <Show
+                  when={plugins().length > 0}
+                  fallback={
+                    <div class="text-14-regular text-text-weak text-center py-4">
+                      Plugins configured in{" "}
+                      <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm">opencode.json</code>
+                    </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>
+    </Popover>
+  )
+}

+ 1 - 1
packages/app/src/context/server.tsx

@@ -211,4 +211,4 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       },
     }
   },
-})
+})

+ 11 - 1
packages/app/src/i18n/ar.ts

@@ -223,6 +223,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
   "dialog.mcp.empty": "لم يتم تكوين MCPs",
 
+  "dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
+  "dialog.plugins.empty": "الإضافات المكونة في opencode.json",
+
   "mcp.status.connected": "متصل",
   "mcp.status.failed": "فشل",
   "mcp.status.needs_auth": "يحتاج إلى مصادقة",
@@ -242,7 +245,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "تعذر الاتصال بالخادم",
   "dialog.server.add.checking": "جارٍ التحقق...",
-  "dialog.server.add.button": "إضافة",
+  "dialog.server.add.button": "إضافة خادم",
   "dialog.server.default.title": "الخادم الافتراضي",
   "dialog.server.default.description":
     "الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.",
@@ -251,6 +254,13 @@ export const dict = {
   "dialog.server.default.clear": "مسح",
   "dialog.server.action.remove": "إزالة الخادم",
 
+  "dialog.server.menu.edit": "تعديل",
+  "dialog.server.menu.default": "تعيين كافتراضي",
+  "dialog.server.menu.defaultRemove": "إزالة الافتراضي",
+  "dialog.server.menu.delete": "حذف",
+  "dialog.server.current": "الخادم الحالي",
+  "dialog.server.status.default": "افتراضي",
+
   "dialog.project.edit.title": "تحرير المشروع",
   "dialog.project.edit.name": "الاسم",
   "dialog.project.edit.icon": "أيقونة",

+ 11 - 1
packages/app/src/i18n/da.ts

@@ -205,6 +205,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} af {{total}} aktiveret",
   "dialog.mcp.empty": "Ingen MCP'er konfigureret",
 
+  "dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper",
+  "dialog.plugins.empty": "Plugins konfigureret i opencode.json",
+
   "mcp.status.connected": "forbundet",
   "mcp.status.failed": "mislykkedes",
   "mcp.status.needs_auth": "kræver godkendelse",
@@ -224,7 +227,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Kunne ikke forbinde til server",
   "dialog.server.add.checking": "Tjekker...",
-  "dialog.server.add.button": "Tilføj",
+  "dialog.server.add.button": "Tilføj server",
   "dialog.server.default.title": "Standardserver",
   "dialog.server.default.description":
     "Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.",
@@ -233,6 +236,13 @@ export const dict = {
   "dialog.server.default.clear": "Ryd",
   "dialog.server.action.remove": "Fjern server",
 
+  "dialog.server.menu.edit": "Rediger",
+  "dialog.server.menu.default": "Sæt som standard",
+  "dialog.server.menu.defaultRemove": "Fjern som standard",
+  "dialog.server.menu.delete": "Slet",
+  "dialog.server.current": "Nuværende server",
+  "dialog.server.status.default": "Standard",
+
   "dialog.project.edit.title": "Rediger projekt",
   "dialog.project.edit.name": "Navn",
   "dialog.project.edit.icon": "Ikon",

+ 11 - 1
packages/app/src/i18n/de.ts

@@ -210,6 +210,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} von {{total}} aktiviert",
   "dialog.mcp.empty": "Keine MCPs konfiguriert",
 
+  "dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt",
+  "dialog.plugins.empty": "In opencode.json konfigurierte Plugins",
+
   "mcp.status.connected": "verbunden",
   "mcp.status.failed": "fehlgeschlagen",
   "mcp.status.needs_auth": "benötigt Authentifizierung",
@@ -229,7 +232,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Verbindung zum Server fehlgeschlagen",
   "dialog.server.add.checking": "Prüfen...",
-  "dialog.server.add.button": "Hinzufügen",
+  "dialog.server.add.button": "Server hinzufügen",
   "dialog.server.default.title": "Standardserver",
   "dialog.server.default.description":
     "Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.",
@@ -238,6 +241,13 @@ export const dict = {
   "dialog.server.default.clear": "Löschen",
   "dialog.server.action.remove": "Server entfernen",
 
+  "dialog.server.menu.edit": "Bearbeiten",
+  "dialog.server.menu.default": "Als Standard festlegen",
+  "dialog.server.menu.defaultRemove": "Standard entfernen",
+  "dialog.server.menu.delete": "Löschen",
+  "dialog.server.current": "Aktueller Server",
+  "dialog.server.status.default": "Standard",
+
   "dialog.project.edit.title": "Projekt bearbeiten",
   "dialog.project.edit.name": "Name",
   "dialog.project.edit.icon": "Icon",

+ 11 - 1
packages/app/src/i18n/en.ts

@@ -223,6 +223,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} of {{total}} enabled",
   "dialog.mcp.empty": "No MCPs configured",
 
+  "dialog.lsp.empty": "LSPs auto-detected from file types",
+  "dialog.plugins.empty": "Plugins configured in opencode.json",
+
   "mcp.status.connected": "connected",
   "mcp.status.failed": "failed",
   "mcp.status.needs_auth": "needs auth",
@@ -242,7 +245,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Could not connect to server",
   "dialog.server.add.checking": "Checking...",
-  "dialog.server.add.button": "Add",
+  "dialog.server.add.button": "Add server",
   "dialog.server.default.title": "Default server",
   "dialog.server.default.description":
     "Connect to this server on app launch instead of starting a local server. Requires restart.",
@@ -251,6 +254,13 @@ export const dict = {
   "dialog.server.default.clear": "Clear",
   "dialog.server.action.remove": "Remove server",
 
+  "dialog.server.menu.edit": "Edit",
+  "dialog.server.menu.default": "Set as default",
+  "dialog.server.menu.defaultRemove": "Remove default",
+  "dialog.server.menu.delete": "Delete",
+  "dialog.server.current": "Current Server",
+  "dialog.server.status.default": "Default",
+
   "dialog.project.edit.title": "Edit project",
   "dialog.project.edit.name": "Name",
   "dialog.project.edit.icon": "Icon",

+ 11 - 1
packages/app/src/i18n/es.ts

@@ -205,6 +205,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
   "dialog.mcp.empty": "No hay MCPs configurados",
 
+  "dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo",
+  "dialog.plugins.empty": "Plugins configurados en opencode.json",
+
   "mcp.status.connected": "conectado",
   "mcp.status.failed": "fallido",
   "mcp.status.needs_auth": "necesita auth",
@@ -224,7 +227,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "No se pudo conectar al servidor",
   "dialog.server.add.checking": "Comprobando...",
-  "dialog.server.add.button": "Añadir",
+  "dialog.server.add.button": "Añadir servidor",
   "dialog.server.default.title": "Servidor predeterminado",
   "dialog.server.default.description":
     "Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.",
@@ -233,6 +236,13 @@ export const dict = {
   "dialog.server.default.clear": "Limpiar",
   "dialog.server.action.remove": "Eliminar servidor",
 
+  "dialog.server.menu.edit": "Editar",
+  "dialog.server.menu.default": "Establecer como predeterminado",
+  "dialog.server.menu.defaultRemove": "Quitar predeterminado",
+  "dialog.server.menu.delete": "Eliminar",
+  "dialog.server.current": "Servidor actual",
+  "dialog.server.status.default": "Predeterminado",
+
   "dialog.project.edit.title": "Editar proyecto",
   "dialog.project.edit.name": "Nombre",
   "dialog.project.edit.icon": "Icono",

+ 11 - 1
packages/app/src/i18n/fr.ts

@@ -205,6 +205,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} sur {{total}} activés",
   "dialog.mcp.empty": "Aucun MCP configuré",
 
+  "dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier",
+  "dialog.plugins.empty": "Plugins configurés dans opencode.json",
+
   "mcp.status.connected": "connecté",
   "mcp.status.failed": "échoué",
   "mcp.status.needs_auth": "nécessite auth",
@@ -224,7 +227,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Impossible de se connecter au serveur",
   "dialog.server.add.checking": "Vérification...",
-  "dialog.server.add.button": "Ajouter",
+  "dialog.server.add.button": "Ajouter un serveur",
   "dialog.server.default.title": "Serveur par défaut",
   "dialog.server.default.description":
     "Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.",
@@ -233,6 +236,13 @@ export const dict = {
   "dialog.server.default.clear": "Effacer",
   "dialog.server.action.remove": "Supprimer le serveur",
 
+  "dialog.server.menu.edit": "Modifier",
+  "dialog.server.menu.default": "Définir par défaut",
+  "dialog.server.menu.defaultRemove": "Supprimer par défaut",
+  "dialog.server.menu.delete": "Supprimer",
+  "dialog.server.current": "Serveur actuel",
+  "dialog.server.status.default": "Défaut",
+
   "dialog.project.edit.title": "Modifier le projet",
   "dialog.project.edit.name": "Nom",
   "dialog.project.edit.icon": "Icône",

+ 11 - 1
packages/app/src/i18n/ja.ts

@@ -204,6 +204,9 @@ export const dict = {
   "dialog.mcp.description": "{{total}}個中{{enabled}}個が有効",
   "dialog.mcp.empty": "MCPが設定されていません",
 
+  "dialog.lsp.empty": "ファイルタイプから自動検出されたLSP",
+  "dialog.plugins.empty": "opencode.jsonで設定されたプラグイン",
+
   "mcp.status.connected": "接続済み",
   "mcp.status.failed": "失敗",
   "mcp.status.needs_auth": "認証が必要",
@@ -223,7 +226,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "サーバーに接続できませんでした",
   "dialog.server.add.checking": "確認中...",
-  "dialog.server.add.button": "追加",
+  "dialog.server.add.button": "サーバーを追加",
   "dialog.server.default.title": "デフォルトサーバー",
   "dialog.server.default.description":
     "ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。",
@@ -232,6 +235,13 @@ export const dict = {
   "dialog.server.default.clear": "クリア",
   "dialog.server.action.remove": "サーバーを削除",
 
+  "dialog.server.menu.edit": "編集",
+  "dialog.server.menu.default": "デフォルトに設定",
+  "dialog.server.menu.defaultRemove": "デフォルト設定を解除",
+  "dialog.server.menu.delete": "削除",
+  "dialog.server.current": "現在のサーバー",
+  "dialog.server.status.default": "デフォルト",
+
   "dialog.project.edit.title": "プロジェクトを編集",
   "dialog.project.edit.name": "名前",
   "dialog.project.edit.icon": "アイコン",

+ 11 - 1
packages/app/src/i18n/ko.ts

@@ -208,6 +208,9 @@ export const dict = {
   "dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨",
   "dialog.mcp.empty": "구성된 MCP 없음",
 
+  "dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP",
+  "dialog.plugins.empty": "opencode.json에 구성된 플러그인",
+
   "mcp.status.connected": "연결됨",
   "mcp.status.failed": "실패",
   "mcp.status.needs_auth": "인증 필요",
@@ -227,7 +230,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "서버에 연결할 수 없습니다",
   "dialog.server.add.checking": "확인 중...",
-  "dialog.server.add.button": "추가",
+  "dialog.server.add.button": "서버 추가",
   "dialog.server.default.title": "기본 서버",
   "dialog.server.default.description":
     "로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.",
@@ -236,6 +239,13 @@ export const dict = {
   "dialog.server.default.clear": "지우기",
   "dialog.server.action.remove": "서버 제거",
 
+  "dialog.server.menu.edit": "편집",
+  "dialog.server.menu.default": "기본값으로 설정",
+  "dialog.server.menu.defaultRemove": "기본값 제거",
+  "dialog.server.menu.delete": "삭제",
+  "dialog.server.current": "현재 서버",
+  "dialog.server.status.default": "기본값",
+
   "dialog.project.edit.title": "프로젝트 편집",
   "dialog.project.edit.name": "이름",
   "dialog.project.edit.icon": "아이콘",

+ 11 - 1
packages/app/src/i18n/no.ts

@@ -226,6 +226,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} av {{total}} aktivert",
   "dialog.mcp.empty": "Ingen MCP-er konfigurert",
 
+  "dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper",
+  "dialog.plugins.empty": "Plugins konfigurert i opencode.json",
+
   "mcp.status.connected": "tilkoblet",
   "mcp.status.failed": "mislyktes",
   "mcp.status.needs_auth": "trenger autentisering",
@@ -245,7 +248,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Kunne ikke koble til server",
   "dialog.server.add.checking": "Sjekker...",
-  "dialog.server.add.button": "Legg til",
+  "dialog.server.add.button": "Legg til server",
   "dialog.server.default.title": "Standardserver",
   "dialog.server.default.description":
     "Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.",
@@ -254,6 +257,13 @@ export const dict = {
   "dialog.server.default.clear": "Tøm",
   "dialog.server.action.remove": "Fjern server",
 
+  "dialog.server.menu.edit": "Rediger",
+  "dialog.server.menu.default": "Sett som standard",
+  "dialog.server.menu.defaultRemove": "Fjern standard",
+  "dialog.server.menu.delete": "Slett",
+  "dialog.server.current": "Gjeldende server",
+  "dialog.server.status.default": "Standard",
+
   "dialog.project.edit.title": "Rediger prosjekt",
   "dialog.project.edit.name": "Navn",
   "dialog.project.edit.icon": "Ikon",

+ 11 - 1
packages/app/src/i18n/pl.ts

@@ -223,6 +223,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} z {{total}} włączone",
   "dialog.mcp.empty": "Brak skonfigurowanych MCP",
 
+  "dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików",
+  "dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json",
+
   "mcp.status.connected": "połączono",
   "mcp.status.failed": "niepowodzenie",
   "mcp.status.needs_auth": "wymaga autoryzacji",
@@ -242,7 +245,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Nie można połączyć się z serwerem",
   "dialog.server.add.checking": "Sprawdzanie...",
-  "dialog.server.add.button": "Dodaj",
+  "dialog.server.add.button": "Dodaj serwer",
   "dialog.server.default.title": "Domyślny serwer",
   "dialog.server.default.description":
     "Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.",
@@ -251,6 +254,13 @@ export const dict = {
   "dialog.server.default.clear": "Wyczyść",
   "dialog.server.action.remove": "Usuń serwer",
 
+  "dialog.server.menu.edit": "Edytuj",
+  "dialog.server.menu.default": "Ustaw jako domyślny",
+  "dialog.server.menu.defaultRemove": "Usuń domyślny",
+  "dialog.server.menu.delete": "Usuń",
+  "dialog.server.current": "Obecny serwer",
+  "dialog.server.status.default": "Domyślny",
+
   "dialog.project.edit.title": "Edytuj projekt",
   "dialog.project.edit.name": "Nazwa",
   "dialog.project.edit.icon": "Ikona",

+ 11 - 1
packages/app/src/i18n/ru.ts

@@ -223,6 +223,9 @@ export const dict = {
   "dialog.mcp.description": "{{enabled}} из {{total}} включено",
   "dialog.mcp.empty": "MCP не настроены",
 
+  "dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов",
+  "dialog.plugins.empty": "Плагины настроены в opencode.json",
+
   "mcp.status.connected": "подключено",
   "mcp.status.failed": "ошибка",
   "mcp.status.needs_auth": "требуется авторизация",
@@ -242,7 +245,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "Не удалось подключиться к серверу",
   "dialog.server.add.checking": "Проверка...",
-  "dialog.server.add.button": "Добавить",
+  "dialog.server.add.button": "Добавить сервер",
   "dialog.server.default.title": "Сервер по умолчанию",
   "dialog.server.default.description":
     "Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.",
@@ -251,6 +254,13 @@ export const dict = {
   "dialog.server.default.clear": "Очистить",
   "dialog.server.action.remove": "Удалить сервер",
 
+  "dialog.server.menu.edit": "Редактировать",
+  "dialog.server.menu.default": "Сделать по умолчанию",
+  "dialog.server.menu.defaultRemove": "Удалить по умолчанию",
+  "dialog.server.menu.delete": "Удалить",
+  "dialog.server.current": "Текущий сервер",
+  "dialog.server.status.default": "По умолч.",
+
   "dialog.project.edit.title": "Редактировать проект",
   "dialog.project.edit.name": "Название",
   "dialog.project.edit.icon": "Иконка",

+ 11 - 1
packages/app/src/i18n/zh.ts

@@ -205,6 +205,9 @@ export const dict = {
   "dialog.mcp.description": "已启用 {{enabled}} / {{total}}",
   "dialog.mcp.empty": "未配置 MCPs",
 
+  "dialog.lsp.empty": "已从文件类型自动检测到 LSPs",
+  "dialog.plugins.empty": "在 opencode.json 中配置的插件",
+
   "mcp.status.connected": "已连接",
   "mcp.status.failed": "失败",
   "mcp.status.needs_auth": "需要授权",
@@ -224,7 +227,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "无法连接到服务器",
   "dialog.server.add.checking": "检查中...",
-  "dialog.server.add.button": "添加",
+  "dialog.server.add.button": "添加服务器",
   "dialog.server.default.title": "默认服务器",
   "dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。",
   "dialog.server.default.none": "未选择服务器",
@@ -232,6 +235,13 @@ export const dict = {
   "dialog.server.default.clear": "清除",
   "dialog.server.action.remove": "移除服务器",
 
+  "dialog.server.menu.edit": "编辑",
+  "dialog.server.menu.default": "设为默认",
+  "dialog.server.menu.defaultRemove": "取消默认",
+  "dialog.server.menu.delete": "删除",
+  "dialog.server.current": "当前服务器",
+  "dialog.server.status.default": "默认",
+
   "dialog.project.edit.title": "编辑项目",
   "dialog.project.edit.name": "名称",
   "dialog.project.edit.icon": "图标",

+ 11 - 1
packages/app/src/i18n/zht.ts

@@ -207,6 +207,9 @@ export const dict = {
   "dialog.mcp.description": "已啟用 {{enabled}} / {{total}}",
   "dialog.mcp.empty": "未設定 MCP",
 
+  "dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs",
+  "dialog.plugins.empty": "在 opencode.json 中設定的外掛程式",
+
   "mcp.status.connected": "已連線",
   "mcp.status.failed": "失敗",
   "mcp.status.needs_auth": "需要授權",
@@ -226,7 +229,7 @@ export const dict = {
   "dialog.server.add.placeholder": "http://localhost:4096",
   "dialog.server.add.error": "無法連線到伺服器",
   "dialog.server.add.checking": "檢查中...",
-  "dialog.server.add.button": "新增",
+  "dialog.server.add.button": "新增伺服器",
   "dialog.server.default.title": "預設伺服器",
   "dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。",
   "dialog.server.default.none": "未選擇伺服器",
@@ -234,6 +237,13 @@ export const dict = {
   "dialog.server.default.clear": "清除",
   "dialog.server.action.remove": "移除伺服器",
 
+  "dialog.server.menu.edit": "編輯",
+  "dialog.server.menu.default": "設為預設",
+  "dialog.server.menu.defaultRemove": "取消預設",
+  "dialog.server.menu.delete": "刪除",
+  "dialog.server.current": "目前伺服器",
+  "dialog.server.status.default": "預設",
+
   "dialog.project.edit.title": "編輯專案",
   "dialog.project.edit.name": "名稱",
   "dialog.project.edit.icon": "圖示",

+ 0 - 1
packages/desktop/src-tauri/Cargo.toml

@@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] }
 tauri-plugin-decorum = "1.1.1"
 comrak = { version = "0.50", default-features = false }
 
-
 [target.'cfg(target_os = "linux")'.dependencies]
 gtk = "0.18.2"
 webkit2gtk = "=2.0.1"

+ 1 - 1
packages/desktop/src-tauri/src/lib.rs

@@ -525,4 +525,4 @@ async fn spawn_local_server(
             break Ok(child);
         }
     }
-}
+}

+ 1 - 1
packages/desktop/src/index.tsx

@@ -417,4 +417,4 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
       </div>
     </Show>
   )
-}
+}

+ 36 - 0
packages/ui/src/components/list.css

@@ -214,6 +214,7 @@
 
         [data-slot="list-item"] {
           display: flex;
+          position: relative;
           width: 100%;
           padding: 6px 8px 6px 8px;
           align-items: center;
@@ -254,6 +255,20 @@
             margin-left: -4px;
           }
 
+          [data-slot="list-item-divider"] {
+            position: absolute;
+            bottom: 0;
+            left: var(--list-divider-inset, 16px);
+            right: var(--list-divider-inset, 16px);
+            height: 1px;
+            background: var(--border-weak-base);
+            pointer-events: none;
+          }
+
+          [data-slot="list-item"]:last-child [data-slot="list-item-divider"] {
+            display: none;
+          }
+
           &[data-active="true"] {
             border-radius: var(--radius-md);
             background: var(--surface-raised-base-hover);
@@ -272,6 +287,27 @@
             outline: none;
           }
         }
+
+        [data-slot="list-item-add"] {
+          display: flex;
+          position: relative;
+          width: 100%;
+          padding: 6px 8px 6px 8px;
+          align-items: center;
+          color: var(--text-strong);
+
+          /* text-14-medium */
+          font-family: var(--font-family-sans);
+          font-size: 14px;
+          font-style: normal;
+          font-weight: var(--font-weight-medium);
+          line-height: var(--line-height-large); /* 142.857% */
+          letter-spacing: var(--letter-spacing-normal);
+
+          [data-component="input"] {
+            width: 100%;
+          }
+        }
       }
     }
   }

+ 82 - 46
packages/ui/src/components/list.tsx

@@ -21,6 +21,16 @@ export interface ListSearchProps {
   action?: JSX.Element
 }
 
+export interface ListAddProps {
+  class?: string
+  render: () => JSX.Element
+}
+
+export interface ListAddProps {
+  class?: string
+  render: () => JSX.Element
+}
+
 export interface ListProps<T> extends FilteredListProps<T> {
   class?: string
   children: (item: T) => JSX.Element
@@ -32,6 +42,8 @@ export interface ListProps<T> extends FilteredListProps<T> {
   filter?: string
   search?: ListSearchProps | boolean
   itemWrapper?: (item: T, node: JSX.Element) => JSX.Element
+  divider?: boolean
+  add?: ListAddProps
 }
 
 export interface ListRef {
@@ -70,6 +82,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
 
   const searchProps = () => (typeof props.search === "object" ? props.search : {})
   const searchAction = () => searchProps().action
+  const addProps = () => props.add
+  const showAdd = () => !!addProps()
 
   const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0
 
@@ -159,6 +173,16 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
     setScrollRef,
   })
 
+  const renderAdd = () => {
+    const add = addProps()
+    if (!add) return null
+    return (
+      <div data-slot="list-item-add" classList={{ [add.class ?? ""]: !!add.class }}>
+        {add.render()}
+      </div>
+    )
+  }
+
   function GroupHeader(groupProps: { category: string }): JSX.Element {
     const [stuck, setStuck] = createSignal(false)
     const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined)
@@ -243,7 +267,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
       </Show>
       <div ref={setScrollRef} data-slot="list-scroll">
         <Show
-          when={flat().length > 0}
+          when={flat().length > 0 || showAdd()}
           fallback={
             <div data-slot="list-empty-state">
               <div data-slot="list-message">{emptyMessage()}</div>
@@ -251,55 +275,67 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
           }
         >
           <For each={grouped.latest}>
-            {(group) => (
-              <div data-slot="list-group">
-                <Show when={group.category}>
-                  <GroupHeader category={group.category} />
-                </Show>
-                <div data-slot="list-items">
-                  <For each={group.items}>
-                    {(item, i) => {
-                      const node = (
-                        <button
-                          data-slot="list-item"
-                          data-key={props.key(item)}
-                          data-active={props.key(item) === active()}
-                          data-selected={item === props.current}
-                          onClick={() => handleSelect(item, i())}
-                          type="button"
-                          onMouseMove={(event) => {
-                            if (!moved(event)) return
-                            setStore("mouseActive", true)
-                            setActive(props.key(item))
-                          }}
-                          onMouseLeave={() => {
-                            if (!store.mouseActive) return
-                            setActive(null)
-                          }}
-                        >
-                          {props.children(item)}
-                          <Show when={item === props.current}>
-                            <span data-slot="list-item-selected-icon">
-                              <Icon name="check-small" />
-                            </span>
-                          </Show>
-                          <Show when={props.activeIcon}>
-                            {(icon) => (
-                              <span data-slot="list-item-active-icon">
-                                <Icon name={icon()} />
+            {(group, groupIndex) => {
+              const isLastGroup = () => groupIndex() === grouped.latest.length - 1
+              return (
+                <div data-slot="list-group">
+                  <Show when={group.category}>
+                    <GroupHeader category={group.category} />
+                  </Show>
+                  <div data-slot="list-items">
+                    <For each={group.items}>
+                      {(item, i) => {
+                        const node = (
+                          <button
+                            data-slot="list-item"
+                            data-key={props.key(item)}
+                            data-active={props.key(item) === active()}
+                            data-selected={item === props.current}
+                            onClick={() => handleSelect(item, i())}
+                            type="button"
+                            onMouseMove={(event) => {
+                              if (!moved(event)) return
+                              setStore("mouseActive", true)
+                              setActive(props.key(item))
+                            }}
+                            onMouseLeave={() => {
+                              if (!store.mouseActive) return
+                              setActive(null)
+                            }}
+                          >
+                            {props.children(item)}
+                            <Show when={item === props.current}>
+                              <span data-slot="list-item-selected-icon">
+                                <Icon name="check-small" />
                               </span>
+                            </Show>
+                            <Show when={props.activeIcon}>
+                              {(icon) => (
+                                <span data-slot="list-item-active-icon">
+                                  <Icon name={icon()} />
+                                </span>
+                              )}
+                            </Show>
+                            {props.divider && (i() !== group.items.length - 1 || (showAdd() && isLastGroup())) && (
+                              <span data-slot="list-item-divider" />
                             )}
-                          </Show>
-                        </button>
-                      )
-                      if (props.itemWrapper) return props.itemWrapper(item, node)
-                      return node
-                    }}
-                  </For>
+                          </button>
+                        )
+                        if (props.itemWrapper) return props.itemWrapper(item, node)
+                        return node
+                      }}
+                    </For>
+                    <Show when={showAdd() && isLastGroup()}>{renderAdd()}</Show>
+                  </div>
                 </div>
-              </div>
-            )}
+              )
+            }}
           </For>
+          <Show when={grouped.latest.length === 0 && showAdd()}>
+            <div data-slot="list-group">
+              <div data-slot="list-items">{renderAdd()}</div>
+            </div>
+          </Show>
         </Show>
       </div>
     </div>