Przeglądaj źródła

feat(app): make server sdk + sync state global (#29285)

Brendan Allan 3 miesięcy temu
rodzic
commit
b0fcba5724

BIN
packages/app/public/assets/Inter.ttf


+ 29 - 25
packages/app/src/app.tsx

@@ -46,6 +46,12 @@ import DirectoryLayout from "@/pages/directory-layout"
 import Layout from "@/pages/layout"
 import { ErrorPage } from "./pages/error"
 import { useCheckServerHealth } from "./utils/server-health"
+import { ServersProvider } from "./context/servers"
+
+if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
+  document.body.classList.remove("text-12-regular")
+  document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
+}
 
 const HomeRoute = lazy(() => import("@/pages/home"))
 const Session = lazy(() => import("@/pages/session"))
@@ -296,31 +302,29 @@ export function AppInterface(props: {
   disableHealthCheck?: boolean
 }) {
   return (
-    <ServerProvider
-      defaultServer={props.defaultServer}
-      disableHealthCheck={props.disableHealthCheck}
-      servers={props.servers}
-    >
-      <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
-        <ServerKey>
-          <QueryProvider>
-            <ServerSDKProvider>
-              <ServerSyncProvider>
-                <Dynamic
-                  component={props.router ?? Router}
-                  root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
-                >
-                  <Route path="/" component={HomeRoute} />
-                  <Route path="/:dir" component={DirectoryLayout}>
-                    <Route path="/" component={() => <Navigate href="session" />} />
-                    <Route path="/session/:id?" component={SessionRoute} />
-                  </Route>
-                </Dynamic>
-              </ServerSyncProvider>
-            </ServerSDKProvider>
-          </QueryProvider>
-        </ServerKey>
-      </ConnectionGate>
+    <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
+      <ServersProvider>
+        <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
+          <ServerKey>
+            <QueryProvider>
+              <ServerSDKProvider>
+                <ServerSyncProvider>
+                  <Dynamic
+                    component={props.router ?? Router}
+                    root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
+                  >
+                    <Route path="/" component={HomeRoute} />
+                    <Route path="/:dir" component={DirectoryLayout}>
+                      <Route path="/" component={() => <Navigate href="session" />} />
+                      <Route path="/session/:id?" component={SessionRoute} />
+                    </Route>
+                  </Dynamic>
+                </ServerSyncProvider>
+              </ServerSDKProvider>
+            </QueryProvider>
+          </ServerKey>
+        </ConnectionGate>
+      </ServersProvider>
     </ServerProvider>
   )
 }

+ 9 - 49
packages/app/src/components/status-popover-body.tsx

@@ -7,16 +7,17 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useNavigate } from "@solidjs/router"
 import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
-import { createStore, reconcile } from "solid-js/store"
+import { createStore } from "solid-js/store"
 import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSDK } from "@/context/sdk"
 import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
-import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
+import { type ServerHealth } from "@/utils/server-health"
 import { useQueryOptions } from "@/context/server-sync"
 import { pathKey } from "@/utils/path-key"
+import { useServers } from "@/context/servers"
 
 const pollMs = 10_000
 
@@ -54,40 +55,6 @@ const listServersByHealth = (
   })
 }
 
-const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
-  const checkServerHealth = useCheckServerHealth()
-  const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
-
-  createEffect(() => {
-    if (!enabled()) {
-      setStatus(reconcile({}))
-      return
-    }
-    const list = servers()
-    let dead = false
-
-    const refresh = async () => {
-      const results: Record<string, ServerHealth> = {}
-      await Promise.all(
-        list.map(async (conn) => {
-          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
-        }),
-      )
-      if (dead) return
-      setStatus(reconcile(results))
-    }
-
-    void refresh()
-    const id = setInterval(() => void refresh(), pollMs)
-    onCleanup(() => {
-      dead = true
-      clearInterval(id)
-    })
-  })
-
-  return status
-}
-
 const useDefaultServerKey = (
   get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
 ) => {
@@ -168,6 +135,7 @@ const useMcpToggleMutation = () => {
 
 export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   const sync = useSync()
+  const servers = useServers()
   const server = useServer()
   const platform = usePlatform()
   const dialog = useDialog()
@@ -192,15 +160,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
     dialogDead = true
     dialogRun += 1
   })
-  const servers = createMemo(() => {
-    const current = server.current
-    const list = server.list
-    if (!current) return list
-    if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
-    return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
-  })
-  const health = useServerHealth(servers, props.shown)
-  const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
+  const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
   const toggleMcp = useMcpToggleMutation()
   const defaultServer = useDefaultServerKey(platform.getDefaultServer)
   const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -226,7 +186,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
       >
         <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
           <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
-            {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
+            {servers.list().length > 0 ? `${servers.list().length} ` : ""}
             {language.t("status.popover.tab.servers")}
           </Tabs.Trigger>
           <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -249,7 +209,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
               <For each={sortedServers()}>
                 {(s) => {
                   const key = ServerConnection.key(s)
-                  const blocked = () => health[key]?.healthy === false
+                  const blocked = () => servers.health[key]?.healthy === false
                   return (
                     <button
                       type="button"
@@ -265,11 +225,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
                         queueMicrotask(() => server.setActive(key))
                       }}
                     >
-                      <ServerHealthIndicator health={health[key]} />
+                      <ServerHealthIndicator health={servers.health[key]} />
                       <ServerRow
                         conn={s}
                         dimmed={blocked()}
-                        status={health[key]}
+                        status={servers.health[key]}
                         class="flex items-center gap-2 w-full min-w-0"
                         nameClass="text-14-regular text-text-base truncate"
                         versionClass="text-12-regular text-text-weak truncate"

+ 8 - 6
packages/app/src/components/status-popover.tsx

@@ -5,15 +5,17 @@ import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
+import { useServers } from "@/context/servers"
 
 const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
 
 export function StatusPopover() {
   const language = useLanguage()
   const server = useServer()
+  const servers = useServers()
   const sync = useSync()
   const [shown, setShown] = createSignal(false)
-  const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
+  const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
   const mcpIssue = createMemo(() => {
     const mcp = Object.values(sync.data.mcp ?? {})
     const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -21,7 +23,8 @@ export function StatusPopover() {
     if (failed) return "critical" as const
     if (warn) return "warning" as const
   })
-  const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
+  const serverHealthy = () => servers.health[server.key]?.healthy === true
+  const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
 
   return (
     <Popover
@@ -43,10 +46,9 @@ export function StatusPopover() {
             classList={{
               "absolute -top-px -right-px size-1.5 rounded-full": true,
               "bg-icon-success-base": ready() && healthy(),
-              "bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
-              "bg-icon-critical-base":
-                server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
-              "bg-border-weak-base": server.healthy() === undefined || !ready(),
+              "bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
+              "bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
+              "bg-border-weak-base": serverHealthy() || !ready(),
             }}
           />
         </div>

+ 3 - 53
packages/app/src/context/server.tsx

@@ -1,8 +1,7 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
+import { type Accessor, batch, createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Persist, persisted } from "@/utils/persist"
-import { useCheckServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
 type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -38,6 +37,7 @@ export function resolveServerList(input: {
   stored: StoredServer[]
 }): Array<ServerConnection.Any> {
   const servers = [
+    ...(input.props ?? []),
     ...input.stored.map((value) =>
       typeof value === "string"
         ? {
@@ -46,7 +46,6 @@ export function resolveServerList(input: {
           }
         : value,
     ),
-    ...(input.props ?? []),
   ]
 
   const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
@@ -122,13 +121,7 @@ export namespace ServerConnection {
 
 export const { use: useServer, provider: ServerProvider } = createSimpleContext({
   name: "Server",
-  init: (props: {
-    defaultServer: ServerConnection.Key
-    disableHealthCheck?: boolean
-    servers?: Array<ServerConnection.Any>
-  }) => {
-    const checkServerHealth = useCheckServerHealth()
-
+  init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
     const [store, setStore, _, ready] = persisted(
       Persist.global("server", ["server.v3"]),
       createStore({
@@ -146,36 +139,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const [state, setState] = createStore({
       active: props.defaultServer,
-      healthy: undefined as boolean | undefined,
     })
 
-    const healthy = () => state.healthy
-
-    function startHealthPolling(conn: ServerConnection.Any) {
-      let alive = true
-      let busy = false
-
-      const run = () => {
-        if (busy) return
-        busy = true
-        void check(conn)
-          .then((next) => {
-            if (!alive) return
-            setState("healthy", next)
-          })
-          .finally(() => {
-            busy = false
-          })
-      }
-
-      run()
-      const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
-      return () => {
-        alive = false
-        clearInterval(interval)
-      }
-    }
-
     function setActive(input: ServerConnection.Key) {
       if (state.active !== input) setState("active", input)
     }
@@ -209,20 +174,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!state.active)
 
-    const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
-
-    createEffect(() => {
-      const current_ = current()
-      if (!current_) return
-
-      if (props.disableHealthCheck) {
-        setState("healthy", true)
-        return
-      }
-      setState("healthy", undefined)
-      onCleanup(startHealthPolling(current_))
-    })
-
     const origin = createMemo(() => projectsKey(state.active))
     const projectsList = createMemo(() => store.projects[origin()] ?? [])
     const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -235,7 +186,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     return {
       ready: isReady,
-      healthy,
       isLocal,
       get key() {
         return state.active

+ 20 - 0
packages/app/src/context/servers.tsx

@@ -0,0 +1,20 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useServer } from "./server"
+import { useServerHealth } from "@/utils/server-health"
+
+export const { use: useServers, provider: ServersProvider } = createSimpleContext({
+  name: "Servers",
+  init: () => {
+    const server = useServer()
+
+    const health = useServerHealth(
+      () => server.list,
+      () => true,
+    )
+
+    return {
+      list: () => server.list,
+      health,
+    }
+  },
+})

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

@@ -168,11 +168,7 @@ if (root instanceof HTMLElement) {
     () => (
       <PlatformProvider value={platform}>
         <AppBaseProviders>
-          <AppInterface
-            defaultServer={ServerConnection.Key.make(getDefaultUrl())}
-            servers={[server]}
-            disableHealthCheck
-          />
+          <AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())} servers={[server]} />
         </AppBaseProviders>
       </PlatformProvider>
     ),

+ 7 - 0
packages/app/src/index.css

@@ -8,6 +8,13 @@
   font-style: normal;
 }
 
+@font-face {
+  font-family: "Inter";
+  src: url("/assets/Inter.ttf") format("truetype");
+  font-weight: normal;
+  font-style: normal;
+}
+
 @layer components {
   @keyframes session-progress-whip {
     0% {

+ 122 - 42
packages/app/src/pages/home.tsx

@@ -1,5 +1,5 @@
 import type { Session } from "@opencode-ai/sdk/v2/client"
-import { createMemo, For, Match, Show, Switch } from "solid-js"
+import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useQuery } from "@tanstack/solid-query"
 import { Button } from "@opencode-ai/ui/button"
@@ -18,23 +18,24 @@ import { DateTime } from "luxon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectDirectory } from "@/components/dialog-select-directory"
 import { DialogSelectServer } from "@/components/dialog-select-server"
-import { useServer } from "@/context/server"
+import { ServerConnection, useServer } from "@/context/server"
 import { useServerSync } from "@/context/server-sync"
 import { useLanguage } from "@/context/language"
 import { useNotification } from "@/context/notification"
 import { usePermission } from "@/context/permission"
 import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
-import { getFilename } from "@opencode-ai/core/util/path"
 import { sessionTitle } from "@/utils/session-title"
 import { pathKey } from "@/utils/path-key"
 import { messageAgentColor } from "@/utils/agent"
 import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
+import { ServerHealthIndicator } from "@/components/server/server-row"
+import { useServers } from "@/context/servers"
 
 const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
 const HOME_SESSION_LIMIT = 15
 const HOME_ROW =
-  "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
-const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
+  "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
+const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-7 gap-2 px-1.5 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
 const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
 
 type HomeSessionRecord = {
@@ -175,8 +176,7 @@ function HomeDesign() {
   return (
     <div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
       <HomeProjectColumn
-        projects={projects()}
-        selected={selectedProject()?.worktree}
+        selectedProject={state.project}
         selectProject={selectProject}
         chooseProject={() => void chooseProject()}
         openSettings={openSettings}
@@ -229,14 +229,20 @@ function HomeDesign() {
 }
 
 function HomeProjectColumn(props: {
-  projects: LocalProject[]
-  selected?: string
+  selectedProject?: string
   selectProject: (directory: string) => void
   chooseProject: () => void
   openSettings: () => void
   openHelp: () => void
   language: ReturnType<typeof useLanguage>
 }) {
+  const servers = useServers()
+  const layout = useLayout()
+  const projects = createMemo(() => layout.projects.list())
+  const selectedProject = createMemo(
+    () => projects().find((project) => project.worktree === props.selectedProject) ?? projects()[0],
+  )
+
   return (
     <aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
       <div class="flex h-7 min-w-0 items-center justify-between pl-3">
@@ -251,38 +257,65 @@ function HomeProjectColumn(props: {
           aria-label={props.language.t("home.project.add")}
         />
       </div>
-      <div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
-        <Show
-          when={props.projects.length > 0}
-          fallback={
-            <button
-              type="button"
-              class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
-              onClick={props.chooseProject}
-            >
-              <IconV2 name="folder-add-left" size="small" />
-              <span>{props.language.t("home.project.add")}</span>
-            </button>
-          }
-        >
-          <For each={props.projects}>
-            {(project) => (
-              <button
-                type="button"
-                data-component="home-project-row"
-                class={HOME_PROJECT_NAV_ROW}
-                classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
-                data-selected={props.selected === project.worktree ? "" : undefined}
-                aria-current={props.selected === project.worktree ? "page" : undefined}
-                onClick={() => props.selectProject(project.worktree)}
-              >
-                <HomeProjectAvatar project={project} />
-                <span>{displayName(project)}</span>
-              </button>
-            )}
-          </For>
-        </Show>
-      </div>
+      <For
+        each={servers.list()}
+        fallback={
+          <ProjectList
+            projects={projects()}
+            selectedProject={props.selectedProject}
+            onSelectedProjectChange={props.selectProject}
+            onChooseProject={props.chooseProject}
+          />
+        }
+      >
+        {(server) => {
+          const key = ServerConnection.key(server)
+          const healthy = () => !!servers.health[key]?.healthy
+          const [open, setOpen] = createSignal(true)
+
+          return (
+            <div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
+              <div class="relative h-7 group">
+                <button
+                  class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
+                  disabled={!healthy()}
+                  onClick={() => setOpen((o) => !o)}
+                >
+                  <div class="size-4 flex items-center justify-center">
+                    <ServerHealthIndicator health={servers.health[key]} />
+                  </div>
+                  <div class="flex flex-row items-center gap-1">
+                    <span>{server.displayName ?? new URL(server.http.url).host}</span>
+                    <Show when={healthy()}>
+                      <IconV2
+                        name="outline-chevron-down"
+                        class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
+                        data-open={open()}
+                      />
+                    </Show>
+                  </div>
+                </button>
+                <IconButtonV2
+                  class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
+                  name="out"
+                  variant="ghost-muted"
+                  size="small"
+                  icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
+                />
+              </div>
+              <Show when={healthy() && open()}>
+                <div class="h-px bg-v2-border-border-base mx-3 my-1" />
+                <ProjectList
+                  projects={projects()}
+                  selectedProject={props.selectedProject}
+                  onSelectedProjectChange={props.selectProject}
+                  onChooseProject={props.chooseProject}
+                />
+              </Show>
+            </div>
+          )
+        }}
+      </For>
       <div class="mt-4 flex min-w-0 flex-col gap-1">
         <button
           type="button"
@@ -464,6 +497,7 @@ function LegacyHome() {
   const platform = usePlatform()
   const dialog = useDialog()
   const navigate = useNavigate()
+  const servers = useServers()
   const server = useServer()
   const language = useLanguage()
   const homedir = createMemo(() => sync.data.path.home)
@@ -475,7 +509,7 @@ function LegacyHome() {
   })
 
   const serverDotClass = createMemo(() => {
-    const healthy = server.healthy()
+    const healthy = servers.health[server.key]?.healthy
     if (healthy === true) return "bg-icon-success-base"
     if (healthy === false) return "bg-icon-critical-base"
     return "bg-border-weak-base"
@@ -581,3 +615,49 @@ function LegacyHome() {
     </div>
   )
 }
+
+function ProjectList(props: {
+  projects: LocalProject[]
+  selectedProject?: string
+  onSelectedProjectChange?(project: string): void
+  onChooseProject?(): void
+}) {
+  const language = useLanguage()
+
+  return (
+    <Show
+      when={props.projects.length > 0}
+      fallback={
+        <button
+          type="button"
+          class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
+          onClick={() => props.onChooseProject?.()}
+        >
+          <IconV2 name="folder-add-left" size="small" />
+          <span>{language.t("home.project.add")}</span>
+        </button>
+      }
+    >
+      <div class="flex flex-col gap-1">
+        <For each={props.projects}>
+          {(project) => (
+            <button
+              type="button"
+              data-component="home-project-row"
+              class={HOME_PROJECT_NAV_ROW}
+              classList={{
+                "bg-v2-overlay-simple-overlay-hover": props.selectedProject === project.worktree,
+              }}
+              data-selected={props.selectedProject === project.worktree ? "" : undefined}
+              aria-current={props.selectedProject === project.worktree ? "page" : undefined}
+              onClick={() => props.onSelectedProjectChange?.(project.worktree)}
+            >
+              <HomeProjectAvatar project={project} />
+              <span>{displayName(project)}</span>
+            </button>
+          )}
+        </For>
+      </div>
+    </Show>
+  )
+}

+ 39 - 1
packages/app/src/utils/server-health.ts

@@ -1,6 +1,8 @@
 import { usePlatform } from "@/context/platform"
-import type { ServerConnection } from "@/context/server"
+import { ServerConnection } from "@/context/server"
 import { createSdkForServer } from "./server"
+import { Accessor, createEffect, onCleanup } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
 
 export type ServerHealth = { healthy: boolean; version?: string }
 
@@ -92,6 +94,8 @@ export async function checkServerHealth(
   return attempt(0).finally(() => timeout?.clear?.())
 }
 
+const pollMs = 10_000
+
 export function useCheckServerHealth() {
   const platform = usePlatform()
   const fetcher = platform.fetch ?? globalThis.fetch
@@ -111,3 +115,37 @@ export function useCheckServerHealth() {
     return promise
   }
 }
+
+export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
+  const checkServerHealth = useCheckServerHealth()
+  const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
+
+  createEffect(() => {
+    if (!enabled()) {
+      setStatus(reconcile({}))
+      return
+    }
+    const list = servers()
+    let dead = false
+
+    const refresh = async () => {
+      const results: Record<string, ServerHealth> = {}
+      await Promise.all(
+        list.map(async (conn) => {
+          results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
+        }),
+      )
+      if (dead) return
+      setStatus(reconcile(results))
+    }
+
+    void refresh()
+    const id = setInterval(() => void refresh(), pollMs)
+    onCleanup(() => {
+      dead = true
+      clearInterval(id)
+    })
+  })
+
+  return status
+}

+ 0 - 1
packages/ui/src/v2/components/icon-button-v2.css

@@ -5,7 +5,6 @@
 }
 
 [data-component="icon-button-v2"] {
-  position: relative;
   display: inline-flex;
   align-items: center;
   justify-content: center;

+ 8 - 0
packages/ui/src/v2/components/icon.tsx

@@ -37,6 +37,14 @@ const icons = {
     viewBox: "0 0 16 16",
     body: `<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor"/>`,
   },
+  "outline-chevron-down": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M5 6.5L8 9.5L11 6.5" stroke="currentColor"/>`,
+  },
+  "outline-dots": {
+    viewBox: "0 0 16 16",
+    body: `<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor"/><path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor"/><path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor"/>`,
+  },
 }
 
 const spriteID = "opencode-v2-icon-sprite"

+ 2 - 0
packages/ui/src/v2/styles/theme.css

@@ -92,6 +92,8 @@
     --v2-illustration-illustration-layer-01: var(--v2-grey-300);
     --v2-illustration-illustration-layer-02: var(--v2-grey-400);
     --v2-illustration-illustration-layer-03: var(--v2-grey-500);
+
+    --font-family-text: "Inter", sans-serif;
   }
 
   /* OS preference fallback (no JS needed) */