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

This commit is contained in:
Brendan Allan
2026-05-26 08:20:57 +08:00
committed by GitHub
parent 2b3ddf9f34
commit b0fcba5724
13 changed files with 248 additions and 182 deletions

Binary file not shown.

View File

@@ -46,6 +46,12 @@ import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" 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 HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
@@ -296,31 +302,29 @@ export function AppInterface(props: {
disableHealthCheck?: boolean disableHealthCheck?: boolean
}) { }) {
return ( return (
<ServerProvider <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
defaultServer={props.defaultServer} <ServersProvider>
disableHealthCheck={props.disableHealthCheck} <ConnectionGate disableHealthCheck={props.disableHealthCheck}>
servers={props.servers} <ServerKey>
> <QueryProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}> <ServerSDKProvider>
<ServerKey> <ServerSyncProvider>
<QueryProvider> <Dynamic
<ServerSDKProvider> component={props.router ?? Router}
<ServerSyncProvider> root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
<Dynamic >
component={props.router ?? Router} <Route path="/" component={HomeRoute} />
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} <Route path="/:dir" component={DirectoryLayout}>
> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/" component={HomeRoute} /> <Route path="/session/:id?" component={SessionRoute} />
<Route path="/:dir" component={DirectoryLayout}> </Route>
<Route path="/" component={() => <Navigate href="session" />} /> </Dynamic>
<Route path="/session/:id?" component={SessionRoute} /> </ServerSyncProvider>
</Route> </ServerSDKProvider>
</Dynamic> </QueryProvider>
</ServerSyncProvider> </ServerKey>
</ServerSDKProvider> </ConnectionGate>
</QueryProvider> </ServersProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider> </ServerProvider>
) )
} }

View File

@@ -7,16 +7,17 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js" 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 { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" 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 { useQueryOptions } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { useServers } from "@/context/servers"
const pollMs = 10_000 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 = ( const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
@@ -168,6 +135,7 @@ const useMcpToggleMutation = () => {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) { export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync() const sync = useSync()
const servers = useServers()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
@@ -192,15 +160,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true dialogDead = true
dialogRun += 1 dialogRun += 1
}) })
const servers = createMemo(() => { const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation() const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) 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.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"> <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")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <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()}> <For each={sortedServers()}>
{(s) => { {(s) => {
const key = ServerConnection.key(s) const key = ServerConnection.key(s)
const blocked = () => health[key]?.healthy === false const blocked = () => servers.health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -265,11 +225,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
queueMicrotask(() => server.setActive(key)) queueMicrotask(() => server.setActive(key))
}} }}
> >
<ServerHealthIndicator health={health[key]} /> <ServerHealthIndicator health={servers.health[key]} />
<ServerRow <ServerRow
conn={s} conn={s}
dimmed={blocked()} dimmed={blocked()}
status={health[key]} status={servers.health[key]}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"

View File

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

View File

@@ -1,8 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" 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 { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean } type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -38,6 +37,7 @@ export function resolveServerList(input: {
stored: StoredServer[] stored: StoredServer[]
}): Array<ServerConnection.Any> { }): Array<ServerConnection.Any> {
const servers = [ const servers = [
...(input.props ?? []),
...input.stored.map((value) => ...input.stored.map((value) =>
typeof value === "string" typeof value === "string"
? { ? {
@@ -46,7 +46,6 @@ export function resolveServerList(input: {
} }
: value, : value,
), ),
...(input.props ?? []),
] ]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>() const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
@@ -122,13 +121,7 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
@@ -146,36 +139,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({ const [state, setState] = createStore({
active: props.defaultServer, 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) { function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input) 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 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 origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo( const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -235,7 +186,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return { return {
ready: isReady, ready: isReady,
healthy,
isLocal, isLocal,
get key() { get key() {
return state.active return state.active

View File

@@ -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,
}
},
})

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import type { ServerConnection } from "@/context/server" import { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./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 } export type ServerHealth = { healthy: boolean; version?: string }
@@ -92,6 +94,8 @@ export async function checkServerHealth(
return attempt(0).finally(() => timeout?.clear?.()) return attempt(0).finally(() => timeout?.clear?.())
} }
const pollMs = 10_000
export function useCheckServerHealth() { export function useCheckServerHealth() {
const platform = usePlatform() const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
@@ -111,3 +115,37 @@ export function useCheckServerHealth() {
return promise 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
}

View File

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

View File

@@ -37,6 +37,14 @@ const icons = {
viewBox: "0 0 16 16", viewBox: "0 0 16 16",
body: `<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor"/>`, 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" const spriteID = "opencode-v2-icon-sprite"

View File

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