Parcourir la source

feat(app): improve servers UI (#30961)

Aarav Sareen il y a 3 mois
Parent
commit
e3a55db5b5

+ 6 - 1
packages/app/src/components/dialog-select-server.tsx

@@ -189,7 +189,7 @@ export function DialogSelectServer() {
   )
 }
 
-export function useServerManagementController(options: { onSelect?: () => void } = {}) {
+export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
   const navigate = useNavigate()
   const server = useServer()
   const tabs = useTabs()
@@ -265,6 +265,11 @@ export function useServerManagementController(options: { onSelect?: () => void }
       }
 
       resetAdd()
+      if (options.navigateOnAdd === false) {
+        server.add(conn)
+        options.onSelect?.()
+        return
+      }
       await select(conn, true)
     },
   }))

+ 65 - 0
packages/app/src/components/server/server-row-menu.tsx

@@ -0,0 +1,65 @@
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { type Component, Show } from "solid-js"
+import { useServerManagementController } from "@/components/dialog-select-server"
+import { useLanguage } from "@/context/language"
+import { ServerConnection } from "@/context/server"
+
+export const ServerRowMenu: Component<{
+  server: ServerConnection.Any
+  controller: ReturnType<typeof useServerManagementController>
+  onEdit: (server: ServerConnection.Http) => void
+  open?: boolean
+  onOpenChange?: (open: boolean) => void
+}> = (props) => {
+  const language = useLanguage()
+  const key = ServerConnection.key(props.server)
+  const builtin = ServerConnection.builtin(props.server)
+  const isDefault = () => props.controller.defaultKey() === key
+
+  return (
+    <MenuV2
+      gutter={4}
+      modal={false}
+      placement="bottom-end"
+      open={props.open}
+      onOpenChange={props.onOpenChange}
+    >
+      <MenuV2.Trigger
+        as={IconButtonV2}
+        variant="ghost-muted"
+        size="small"
+        icon={<IconV2 name="outline-dots" />}
+        aria-label={language.t("common.moreOptions")}
+      />
+      <MenuV2.Portal>
+        <MenuV2.Content>
+          <MenuV2.Group>
+            <MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
+            <MenuV2.Item
+              disabled={builtin || props.server.type !== "http"}
+              onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
+            >
+              {language.t("dialog.server.menu.edit")}
+            </MenuV2.Item>
+            <Show when={props.controller.canDefault() && !isDefault()}>
+              <MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
+                {language.t("dialog.server.menu.default")}
+              </MenuV2.Item>
+            </Show>
+            <Show when={props.controller.canDefault() && isDefault()}>
+              <MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
+                {language.t("dialog.server.menu.defaultRemove")}
+              </MenuV2.Item>
+            </Show>
+            <MenuV2.Separator />
+            <MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
+              {language.t("dialog.server.menu.delete")}
+            </MenuV2.Item>
+          </MenuV2.Group>
+        </MenuV2.Content>
+      </MenuV2.Portal>
+    </MenuV2>
+  )
+}

+ 129 - 0
packages/app/src/components/settings-v2/dialog-server-v2.tsx

@@ -0,0 +1,129 @@
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { useLanguage } from "@/context/language"
+import { type ServerConnection } from "@/context/server"
+import { useServerManagementController } from "../dialog-select-server"
+import "./settings-v2.css"
+
+export const DialogServerV2: Component<{
+  mode: "add" | "edit"
+  server?: ServerConnection.Http
+}> = (props) => {
+  const dialog = useDialog()
+  const language = useLanguage()
+  const controller = useServerManagementController({
+    onSelect: () => dialog.close(),
+    navigateOnAdd: false,
+  })
+  const [opened, setOpened] = createSignal(false)
+
+  onMount(() => {
+    if (props.mode === "add") controller.startAdd()
+    if (props.mode === "edit" && props.server) controller.startEdit(props.server)
+    setOpened(true)
+  })
+
+  onCleanup(() => {
+    controller.resetForm()
+  })
+
+  createEffect(() => {
+    if (!opened()) return
+    if (controller.isFormMode()) return
+    dialog.close()
+  })
+
+  const keyDown = (event: KeyboardEvent) => {
+    if (event.key !== "Enter" || event.isComposing) return
+    event.preventDefault()
+    controller.submitForm()
+  }
+
+  const title = () =>
+    props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
+
+  const submitLabel = () => {
+    if (controller.formBusy()) return language.t("dialog.server.add.checking")
+    if (props.mode === "add") return language.t("dialog.server.add.button")
+    return language.t("common.save")
+  }
+
+  return (
+    <Dialog title={title()} fit class="settings-v2-server-dialog">
+      <div class="flex w-full min-w-0 flex-1 flex-col px-4">
+        <div class="flex w-full min-w-0 flex-col gap-6">
+          <div class="flex w-full min-w-0 flex-col gap-2">
+            <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
+            <TextInputV2
+              type="text"
+              appearance="large"
+              class="!w-full self-stretch"
+              value={controller.formValue()}
+              placeholder={language.t("dialog.server.add.placeholder")}
+              invalid={!!controller.formError()}
+              disabled={controller.formBusy()}
+              autofocus
+              onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
+              onKeyDown={keyDown}
+            />
+            <Show when={controller.formError()}>
+              <span class="settings-v2-server-dialog-error">{controller.formError()}</span>
+            </Show>
+          </div>
+          <div class="flex w-full min-w-0 flex-col gap-2">
+            <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
+            <TextInputV2
+              type="text"
+              appearance="large"
+              class="!w-full self-stretch"
+              value={controller.formName()}
+              placeholder={language.t("dialog.server.add.namePlaceholder")}
+              disabled={controller.formBusy()}
+              onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
+              onKeyDown={keyDown}
+            />
+          </div>
+          <div class="grid w-full min-w-0 grid-cols-2 gap-4">
+            <div class="flex min-w-0 flex-col gap-2">
+              <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
+              <TextInputV2
+                type="text"
+                appearance="large"
+                class="!w-full self-stretch"
+                value={controller.formUsername()}
+                placeholder={language.t("dialog.server.add.usernamePlaceholder")}
+                disabled={controller.formBusy()}
+                onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
+                onKeyDown={keyDown}
+              />
+            </div>
+            <div class="flex min-w-0 flex-col gap-2">
+              <label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
+              <TextInputV2
+                type="password"
+                appearance="large"
+                class="!w-full self-stretch"
+                value={controller.formPassword()}
+                placeholder={language.t("dialog.server.add.passwordPlaceholder")}
+                disabled={controller.formBusy()}
+                onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
+                onKeyDown={keyDown}
+              />
+            </div>
+          </div>
+        </div>
+      </div>
+      <DialogFooter>
+        <ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
+          {language.t("common.cancel")}
+        </ButtonV2>
+        <ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
+          {submitLabel()}
+        </ButtonV2>
+      </DialogFooter>
+    </Dialog>
+  )
+}

+ 6 - 6
packages/app/src/components/settings-v2/dialog-settings-v2.tsx

@@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds"
 import { SettingsProvidersV2 } from "./providers"
 import { SettingsModelsV2 } from "./models"
 import "./settings-v2.css"
-import { SettingsServers } from "../settings-servers"
+import { SettingsServersV2 } from "./servers"
 
 export const DialogSettings: Component = () => {
   const language = useLanguage()
@@ -33,16 +33,16 @@ export const DialogSettings: Component = () => {
                       <Icon name="keyboard" />
                       {language.t("settings.tab.shortcuts")}
                     </TabsV2.Trigger>
-                    <TabsV2.Trigger value="servers">
-                      <Icon name="server" />
-                      {language.t("status.popover.tab.servers")}
-                    </TabsV2.Trigger>
                   </div>
                 </div>
 
                 <div class="flex flex-col gap-1.5">
                   <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
                   <div class="flex flex-col gap-1.5 w-full">
+                    <TabsV2.Trigger value="servers">
+                      <Icon name="server" />
+                      {language.t("status.popover.tab.servers")}
+                    </TabsV2.Trigger>
                     <TabsV2.Trigger value="providers">
                       <Icon name="providers" />
                       {language.t("settings.providers.title")}
@@ -68,7 +68,7 @@ export const DialogSettings: Component = () => {
           <SettingsKeybinds v2 />
         </TabsV2.Content>
         <TabsV2.Content value="servers" class="settings-v2-panel">
-          <SettingsServers />
+          <SettingsServersV2 />
         </TabsV2.Content>
         <TabsV2.Content value="providers" class="settings-v2-panel">
           <SettingsProvidersV2 />

+ 139 - 0
packages/app/src/components/settings-v2/servers.tsx

@@ -0,0 +1,139 @@
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Tag } from "@opencode-ai/ui/v2/badge-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import fuzzysort from "fuzzysort"
+import { type Component, For, Show, createMemo } from "solid-js"
+import { createStore } from "solid-js/store"
+import { ServerRowMenu } from "@/components/server/server-row-menu"
+import { ServerHealthIndicator } from "@/components/server/server-row"
+import { useLanguage } from "@/context/language"
+import { ServerConnection, serverName } from "@/context/server"
+import { useServerManagementController } from "../dialog-select-server"
+import { DialogServerV2 } from "./dialog-server-v2"
+import { SettingsListV2 } from "./parts/list"
+import "./settings-v2.css"
+
+export const SettingsServersV2: Component = () => {
+  const dialog = useDialog()
+  const language = useLanguage()
+  const controller = useServerManagementController()
+  const [store, setStore] = createStore({ filter: "" })
+
+  const showSearch = createMemo(() => controller.sortedItems().length > 1)
+
+  const filtered = createMemo(() => {
+    const items = controller.sortedItems()
+    const query = store.filter.trim()
+    if (!query) return items
+    return fuzzysort
+      .go(query, items, {
+        keys: [(item) => serverName(item), (item) => item.http.url],
+      })
+      .map((result) => result.obj)
+  })
+
+  const openAdd = () => {
+    dialog.push(() => <DialogServerV2 mode="add" />)
+  }
+
+  const openEdit = (server: ServerConnection.Http) => {
+    dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
+  }
+
+  return (
+    <>
+      <div
+        class="settings-v2-tab-header settings-v2-servers-header"
+        classList={{ "settings-v2-tab-header--stacked": showSearch() }}
+      >
+        <div class="settings-v2-tab-header-row">
+          <h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
+          <ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
+            {language.t("dialog.server.add.button")}
+          </ButtonV2>
+        </div>
+        <Show when={showSearch()}>
+          <div class="settings-v2-tab-search">
+            <TextInputV2
+              type="search"
+              appearance="base"
+              value={store.filter}
+              onInput={(event) => setStore("filter", event.currentTarget.value)}
+              placeholder={language.t("dialog.server.search.placeholder")}
+              spellcheck={false}
+              autocorrect="off"
+              autocomplete="off"
+              autocapitalize="off"
+              aria-label={language.t("dialog.server.search.placeholder")}
+            />
+            <Show when={store.filter}>
+              <IconButtonV2
+                type="button"
+                variant="ghost-muted"
+                size="small"
+                class="settings-v2-tab-search-clear"
+                icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
+                onClick={() => setStore("filter", "")}
+              />
+            </Show>
+          </div>
+        </Show>
+      </div>
+
+      <div class="settings-v2-tab-body settings-v2-servers">
+        <Show
+          when={filtered().length > 0}
+          fallback={
+            <div class="settings-v2-servers-status">
+              <span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
+              <Show when={store.filter}>
+                <span class="settings-v2-servers-status-filter">&quot;{store.filter}&quot;</span>
+              </Show>
+            </div>
+          }
+        >
+          <SettingsListV2>
+            <For each={filtered()}>
+              {(item) => {
+                const key = ServerConnection.key(item)
+                const health = () => controller.status()[key]
+                const isDefault = () => controller.defaultKey() === key
+                return (
+                  <div class="settings-v2-servers-row">
+                    <div class="settings-v2-servers-lead">
+                      <ServerHealthIndicator health={health()} />
+                      <div class="settings-v2-servers-copy">
+                        <span class="settings-v2-servers-name">{serverName(item)}</span>
+                        <span class="settings-v2-servers-meta">
+                          <Show when={health()?.version}>v{health()?.version}</Show>
+                          <Show when={health()?.version && item.type === "http"}> • </Show>
+                          <Show
+                            when={item.type === "http" && item.http.username}
+                            fallback={
+                              <Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>
+                            }
+                          >
+                            {item.http.username}
+                          </Show>
+                        </span>
+                      </div>
+                    </div>
+                    <div class="settings-v2-servers-actions">
+                      <Show when={controller.canDefault() && isDefault()}>
+                        <Tag>{language.t("dialog.server.status.default")}</Tag>
+                      </Show>
+                      <ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
+                    </div>
+                  </div>
+                )
+              }}
+            </For>
+          </SettingsListV2>
+        </Show>
+      </div>
+    </>
+  )
+}

+ 141 - 0
packages/app/src/components/settings-v2/settings-v2.css

@@ -511,3 +511,144 @@
 .settings-v2-shortcuts-status-filter {
   color: var(--v2-text-text-base);
 }
+
+.settings-v2-tab-body.settings-v2-servers {
+  gap: 0;
+}
+
+.settings-v2-tab-header.settings-v2-servers-header {
+  padding-bottom: 24px;
+}
+
+.settings-v2-servers-header .settings-v2-tab-header-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
+  gap: 24px;
+  padding-bottom: 24px;
+}
+
+.settings-v2-servers [data-component="settings-v2-list"] {
+  display: flex;
+  flex-direction: column;
+  gap: 0;
+  padding: 20px;
+  border-radius: 6px;
+}
+
+.settings-v2-servers-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.settings-v2-servers-row:not(:last-child) {
+  padding-bottom: 16px;
+  margin-bottom: 16px;
+  border-bottom: 0.5px solid var(--v2-border-border-base);
+}
+
+.settings-v2-servers-actions {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 8px;
+}
+
+.settings-v2-servers-lead {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.settings-v2-servers-copy {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.settings-v2-servers-name {
+  font-size: 13px;
+  font-weight: 530;
+  line-height: 1;
+  color: var(--v2-text-text-base);
+}
+
+.settings-v2-servers-meta {
+  font-size: 11px;
+  font-weight: 440;
+  line-height: 1;
+  color: var(--v2-text-text-muted);
+}
+
+.settings-v2-servers-status {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  padding-block: 48px;
+  font-size: 13px;
+  font-weight: 440;
+  line-height: 1;
+  color: var(--v2-text-text-muted);
+  text-align: center;
+}
+
+.settings-v2-servers-status-filter {
+  color: var(--v2-text-text-base);
+}
+
+[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
+  width: 480px;
+  max-width: calc(100vw - 32px);
+  height: auto;
+  border-radius: 8px;
+  align-items: stretch;
+}
+
+[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
+  align-items: stretch;
+  width: 100%;
+}
+
+[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
+  align-items: center;
+  padding: 24px 24px 0;
+}
+
+[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  flex-direction: column;
+  align-items: stretch;
+}
+
+[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
+  padding: 24px;
+}
+
+.settings-v2-server-dialog-label {
+  font-size: 13px;
+  font-weight: 530;
+  line-height: 1;
+  color: var(--v2-text-text-base);
+}
+
+.settings-v2-server-dialog-error {
+  font-size: 11px;
+  font-weight: 440;
+  line-height: 1;
+  color: var(--v2-state-fg-danger);
+}

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

@@ -202,6 +202,8 @@ export namespace ServerConnection {
 
   export type Key = string & { _brand: "Key" }
   export const Key = { make: (v: string) => v as Key }
+
+  export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
 }
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({

+ 70 - 25
packages/app/src/pages/home.tsx

@@ -20,7 +20,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 { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
+import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
 import { ServerConnection, useServer } from "@/context/server"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
@@ -45,7 +46,9 @@ import { sessionPermissionRequest } from "@/pages/session/composer/session-reque
 import { useGlobal } from "@/context/global"
 import { useCommand } from "@/context/command"
 import { useSettings } from "@/context/settings"
+import { ServerRowMenu } from "@/components/server/server-row-menu"
 import { ServerHealthIndicator } from "@/components/server/server-row"
+import { type ServerHealth } from "@/utils/server-health"
 
 const HOME_SESSION_LIMIT = 15
 const HOME_ROW_LAYOUT =
@@ -497,6 +500,8 @@ function HomeProjectColumn(props: {
   language: ReturnType<typeof useLanguage>
 }) {
   const global = useGlobal()
+  const dialog = useDialog()
+  const controller = useServerManagementController({ navigateOnAdd: false })
   return (
     <aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
       <div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
@@ -524,29 +529,17 @@ function HomeProjectColumn(props: {
             const serverCtx = global.createServerCtx(item)
             return (
               <div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
-                <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
-                  <button
-                    type="button"
-                    class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
-                    data-selected={props.selected.server === key && !props.selected.directory ? "" : undefined}
-                    disabled={!healthy()}
-                    onClick={() => props.focusServer(item)}
-                  >
-                    <div class="flex size-4 shrink-0 items-center justify-center">
-                      <ServerHealthIndicator health={global.servers.health[key]} />
-                    </div>
-                    <span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
-                  </button>
-                  <IconButtonV2
-                    data-action="home-add-project"
-                    variant="ghost-muted"
-                    size="small"
-                    class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
-                    icon={<IconV2 name="folder-add-left" />}
-                    aria-label={props.language.t("home.project.add")}
-                    onClick={() => props.chooseProject(item)}
-                  />
-                </div>
+                <HomeServerRow
+                  server={item}
+                  selected={props.selected.server === key && !props.selected.directory}
+                  healthy={healthy()}
+                  health={global.servers.health[key]}
+                  controller={controller}
+                  focusServer={props.focusServer}
+                  chooseProject={props.chooseProject}
+                  openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
+                  language={props.language}
+                />
                 <Show when={healthy()}>
                   <div class="mx-3 h-px bg-v2-border-border-base" />
                   <HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
@@ -556,7 +549,7 @@ function HomeProjectColumn(props: {
           }}
         </For>
       </Show>
-      <div class="flex min-w-0 flex-col gap-1">
+      <div class="mt-4 flex min-w-0 flex-col gap-1">
         <button
           type="button"
           class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -578,6 +571,58 @@ function HomeProjectColumn(props: {
   )
 }
 
+function HomeServerRow(props: {
+  server: ServerConnection.Any
+  selected: boolean
+  healthy: boolean
+  health: ServerHealth | undefined
+  controller: ReturnType<typeof useServerManagementController>
+  focusServer: (server: ServerConnection.Any) => void
+  chooseProject: (server: ServerConnection.Any) => void
+  openEdit: (server: ServerConnection.Http) => void
+  language: ReturnType<typeof useLanguage>
+}) {
+  const [state, setState] = createStore({ menuOpen: false })
+  return (
+    <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
+      <button
+        type="button"
+        class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
+        data-selected={props.selected ? "" : undefined}
+        disabled={!props.healthy}
+        onClick={() => props.focusServer(props.server)}
+      >
+        <div class="flex size-4 shrink-0 items-center justify-center">
+          <ServerHealthIndicator health={props.health} />
+        </div>
+        <span class={HOME_PROJECT_NAV_LABEL}>
+          {props.server.displayName ?? new URL(props.server.http.url).host}
+        </span>
+      </button>
+      <div
+        class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
+        data-menu={state.menuOpen}
+      >
+        <ServerRowMenu
+          server={props.server}
+          controller={props.controller}
+          onEdit={props.openEdit}
+          open={state.menuOpen}
+          onOpenChange={(open) => setState("menuOpen", open)}
+        />
+        <IconButtonV2
+          data-action="home-add-project"
+          variant="ghost-muted"
+          size="small"
+          icon={<IconV2 name="folder-add-left" />}
+          aria-label={props.language.t("home.project.add")}
+          onClick={() => props.chooseProject(props.server)}
+        />
+      </div>
+    </div>
+  )
+}
+
 function HomeProjectList(props: {
   server: ServerConnection.Any
   projects: LocalProject[]

+ 63 - 30
packages/ui/src/context/dialog.tsx

@@ -11,6 +11,7 @@ import {
   useContext,
   type JSX,
   startTransition,
+  For,
 } from "solid-js"
 import { Dialog as Kobalte } from "@kobalte/core/dialog"
 import { makeEventListener } from "@solid-primitives/event-listener"
@@ -29,7 +30,7 @@ type Active = {
 const Context = createContext<ReturnType<typeof init>>()
 
 function init() {
-  const [active, setActive] = createSignal<Active | undefined>()
+  const [stack, setStack] = createSignal<Active[]>([])
   const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
   const lock = { value: false }
 
@@ -39,14 +40,15 @@ function init() {
     timer.current = undefined
   })
 
-  const close = () => {
-    const current = active()
+  const close = (id?: string) => {
+    const items = stack()
+    const current = id ? items.find((item) => item.id === id) : items.at(-1)
     if (!current || lock.value) return
     lock.value = true
     current.onClose?.()
     current.setClosing(true)
 
-    const id = current.id
+    const closed = current.id
     if (timer.current !== undefined) {
       clearTimeout(timer.current)
       timer.current = undefined
@@ -55,13 +57,13 @@ function init() {
     timer.current = setTimeout(() => {
       timer.current = undefined
       current.dispose()
-      if (active()?.id === id) setActive(undefined)
+      setStack((items) => items.filter((item) => item.id !== closed))
       lock.value = false
     }, 100)
   }
 
   createEffect(() => {
-    if (!active()) return
+    if (stack().length === 0) return
 
     const onKeyDown = (event: KeyboardEvent) => {
       if (event.key !== "Escape") return
@@ -73,21 +75,9 @@ function init() {
     makeEventListener(window, "keydown", onKeyDown, { capture: true })
   })
 
-  const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
-    // Immediately dispose any existing dialog when showing a new one
-    const current = active()
-    if (current) {
-      current.dispose()
-      setActive(undefined)
-    }
-
-    if (timer.current !== undefined) {
-      clearTimeout(timer.current)
-      timer.current = undefined
-    }
-    lock.value = false
-
+  const mount = (element: DialogElement, owner: Owner, onClose: (() => void) | undefined, layer: number) => {
     const id = Math.random().toString(36).slice(2)
+    const zIndex = 50 + layer * 10
     let dispose: (() => void) | undefined
     let setClosing: ((closing: boolean) => void) | undefined
 
@@ -102,12 +92,29 @@ function init() {
             open={!closing()}
             onOpenChange={(open: boolean) => {
               if (open) return
-              close()
+              close(id)
             }}
           >
             <Kobalte.Portal>
-              <Kobalte.Overlay data-component="dialog-overlay" onClick={close} />
-              {element()}
+              <Kobalte.Overlay
+                data-component="dialog-overlay"
+                style={{ "z-index": String(zIndex) }}
+                onClick={() => close(id)}
+              />
+              <div
+                data-dialog-layer={layer}
+                style={{
+                  position: "fixed",
+                  inset: "0",
+                  "z-index": String(zIndex),
+                  display: "flex",
+                  "align-items": "center",
+                  "justify-content": "center",
+                  "pointer-events": "none",
+                }}
+              >
+                {element()}
+              </div>
             </Kobalte.Portal>
           </Kobalte>
         )
@@ -116,15 +123,35 @@ function init() {
 
     if (!dispose || !setClosing) return
 
-    setActive({ id, node, dispose, owner, onClose, setClosing })
+    const active: Active = { id, node, dispose, owner, onClose, setClosing }
+    setStack((items) => [...items, active])
+  }
+
+  const push = (element: DialogElement, owner: Owner, onClose?: () => void) => {
+    if (timer.current !== undefined) {
+      clearTimeout(timer.current)
+      timer.current = undefined
+    }
+    lock.value = false
+    mount(element, owner, onClose, stack().length)
+  }
+
+  const show = (element: DialogElement, owner: Owner, onClose?: () => void) => {
+    for (const item of stack()) item.dispose()
+    setStack([])
+    if (timer.current !== undefined) {
+      clearTimeout(timer.current)
+      timer.current = undefined
+    }
+    lock.value = false
+    mount(element, owner, onClose, 0)
   }
 
   return {
-    get active() {
-      return active()
-    },
+    stack,
     close,
     show,
+    push,
   }
 }
 
@@ -133,7 +160,9 @@ export function DialogProvider(props: ParentProps) {
   return (
     <Context.Provider value={ctx}>
       {props.children}
-      <div data-component="dialog-stack">{ctx.active?.node}</div>
+      <div data-component="dialog-stack">
+        <For each={ctx.stack()}>{(item) => item.node}</For>
+      </div>
     </Context.Provider>
   )
 }
@@ -151,12 +180,16 @@ export function useDialog() {
 
   return {
     get active() {
-      return ctx.active
+      return ctx.stack().at(-1)
     },
     show(element: DialogElement, onClose?: () => void) {
-      const base = ctx.active?.owner ?? owner
+      const base = ctx.stack().at(-1)?.owner ?? owner
       return startTransition(() => ctx.show(element, base, onClose))
     },
+    push(element: DialogElement, onClose?: () => void) {
+      const base = ctx.stack().at(-1)?.owner ?? owner
+      return startTransition(() => ctx.push(element, base, onClose))
+    },
     close() {
       ctx.close()
     },

+ 7 - 0
packages/ui/src/v2/components/menu-v2.css

@@ -1,3 +1,8 @@
+/* Above modal dialogs (z-index 50); matches select-v2-content. */
+[data-popper-positioner]:has([data-component="menu-v2-content"]) {
+  z-index: 60;
+}
+
 [data-component="menu-v2-content"] {
   box-sizing: border-box;
   display: flex;
@@ -5,6 +10,8 @@
   align-items: stretch;
   padding: 2px;
   min-width: 160px;
+  z-index: 60;
+  pointer-events: auto;
 
   background: var(--v2-background-bg-layer-01);
   border-radius: 6px;