feat(app): improve desktop multi-server support (#30678)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-06-05 16:30:02 +10:00 committed by GitHub
commit 7f33576f46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1523 additions and 841 deletions

View file

@ -6,21 +6,23 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { ServerConnection } from "@/context/server"
import { useGlobal } from "@/context/global"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject }) {
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const global = useGlobal()
const language = useLanguage()
const serverCtx = createMemo(() => global.createServerCtx(props.server))
const serverSDK = () => serverCtx().sdk
const serverSync = () => serverCtx().sync
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
@ -78,19 +80,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK.client.project.update({
await serverSDK().client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync.project.meta(props.project.worktree, {
serverSync().project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },

View file

@ -18,6 +18,7 @@ import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
const DEFAULT_USERNAME = "opencode"
@ -191,6 +192,7 @@ export function DialogSelectServer() {
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
const global = useGlobal()
const platform = usePlatform()
const language = useLanguage()
@ -311,12 +313,14 @@ export function useServerManagementController(options: { onSelect?: () => void }
}))
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
const originalKey = ServerConnection.key(original)
const active = server.key
tabs.removeServer(originalKey)
const newConn = server.add(next)
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
const nextActive = active === originalKey ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original))
server.remove(originalKey)
}
const items = createMemo(() => {
@ -501,6 +505,7 @@ export function useServerManagementController(options: { onSelect?: () => void }
})
async function handleRemove(url: ServerConnection.Key) {
tabs.removeServer(url)
server.remove(url)
if ((await platform.getDefaultServer?.()) === url) {
void platform.setDefaultServer?.(null)

View file

@ -127,6 +127,7 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({
useSDK: () => {
const sdk = {
scope: "local",
directory: "/repo/main",
client: rootClient,
url: "http://localhost:4096",

View file

@ -18,6 +18,7 @@ import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
type PendingPrompt = {
abort: AbortController
@ -212,6 +213,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk.scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
@ -232,11 +234,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.onAbort?.()
const queued = pending.get(sessionID)
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(sessionID)
pending.delete(key)
return Promise.resolve()
}
return sdk.client.session
@ -341,7 +344,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
return
}
WorktreeState.pending(createdWorktree.directory)
WorktreeState.pending(sdk.scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
@ -500,7 +503,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sessionDirectory)
const worktree = WorktreeState.get(sdk.scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
@ -517,7 +520,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
restoreInput()
}
pending.set(session.id, { abort: controller, cleanup })
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
@ -544,11 +547,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}, timeoutMs)
})
const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
const result = await Promise.race([WorktreeState.wait(sdk.scope, sessionDirectory), abortWait, timeout]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session.id)
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@ -563,7 +566,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(session.id)
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
}

View file

@ -178,11 +178,11 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const globalSdk = useServerSDK()
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
serverSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),

View file

@ -179,12 +179,12 @@ export const SettingsGeneralV2: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useServerSync()
const globalSdk = useServerSDK()
const serverSync = useServerSync()
const serverSdk = useServerSDK()
const [shells] = createResource(
() =>
globalSdk.client.pty
serverSdk.client.pty
.shells()
.then((res) => res.data ?? [])
.catch(() => [] as ShellOption[]),
@ -208,11 +208,11 @@ export const SettingsGeneralV2: Component = () => {
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = globalSync.data.config.shell
const current = serverSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@ -347,7 +347,7 @@ export const SettingsGeneralV2: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value })
serverSync.updateConfig({ shell: option.value })
}}
/>
</SettingsRowV2>

View file

@ -33,8 +33,8 @@ const PROVIDER_ICON_SIZE = 16
export const SettingsProvidersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const globalSDK = useServerSDK()
const globalSync = useServerSync()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders()
const connected = createMemo(() => {
@ -77,7 +77,7 @@ export const SettingsProvidersV2: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@ -85,11 +85,11 @@ export const SettingsProvidersV2: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = globalSync.data.config.disabled_providers ?? []
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
globalSync.set("config", "disabled_providers", next)
serverSync.set("config", "disabled_providers", next)
await globalSync
await serverSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@ -100,7 +100,7 @@ export const SettingsProvidersV2: Component = () => {
})
})
.catch((err: unknown) => {
globalSync.set("config", "disabled_providers", before)
serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@ -108,14 +108,14 @@ export const SettingsProvidersV2: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await serverSdk.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await globalSDK.client.auth
await serverSdk.client.auth
.remove({ providerID })
.then(async () => {
await globalSDK.client.global.dispose()
await serverSdk.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",

View file

@ -1,5 +1,5 @@
import { createEffect, createMemo, createResource, For, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
@ -17,20 +17,16 @@ import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { makeEventListener } from "@solid-primitives/event-listener"
import {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events"
import { Persist, persisted } from "@/utils/persist"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { decode64 } from "@/utils/base64"
import { ServerConnection, useServer } from "@/context/server"
import { tabHref, useTabs, type Tab } from "@/context/tabs"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@ -58,8 +54,6 @@ const v2TitlebarHeight = 36
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = {
version: () => string | undefined
installing: () => boolean
@ -73,6 +67,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const settings = useSettings()
const theme = useTheme()
const server = useServer()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
@ -256,93 +251,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return `/${base64Encode(project.worktree)}/session`
}
type SessionTab = { type: "session"; dirBase64: string; sessionId: string }
type Tab = SessionTab
const tabHref = (tab: Tab) => {
if (tab.type === "session") {
return makeSessionHref(tab.dirBase64, tab.sessionId)
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
return "/"
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = persisted(Persist.global("tabs"), createStore<Tab[]>([]))
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.type === "session" && tabHref(t) === tabHref({ type: "session", ...tab })))
return
tabs.push({ type: "session", ...tab })
}),
)
},
removeTab: (index: number) => {
if (index < 0) return
void startTransition(() => {
setStore(
produce((tabs) => {
const nextTab = tabs[index + 1] ?? tabs[index - 1]
tabs.splice(index, 1)
if (nextTab) navigate(tabHref(nextTab))
else navigate("/")
}),
)
})
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref
? tabs.findIndex((tab) => tab.type === "session" && tabHref(tab) === currentHref)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent =
currentTab?.type === "session" &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab =
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigate(tabHref(nextTab))
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return
if (route.type === "dir-new-sesssion") {
}
if (route.type === "session") {
const main = tabsStore.find((s) => s.type === "session" && s.sessionId === route.sessionId)
const main = tabsStore.find(
(item) =>
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
)
if (main) return main
const sync = serverSync.createDirSyncContext(route.dir)
const session = sync.session.get(route.sessionId)
if (session?.parentID) {
const parentID = session.parentID
const parent = tabsStore.find((s) => s.type === "session" && s.sessionId === parentID)
const parent = tabsStore.find(
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
)
if (parent) return parent
}
}
@ -352,6 +292,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
createEffect(() => {
const route = layout.route()
if (!tabs.ready()) return
const tab = currentTab()
if (tab) return
@ -360,10 +301,12 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const session = sync.session.get(route.sessionId)
if (!session) return
const sessionId = session.parentID ?? session.id
tabsStoreActions.addSessionTab({
const next = {
server: route.server ?? server.key,
dirBase64: route.dirBase64,
sessionId,
})
}
tabsStoreActions.addSessionTab(next)
}
})
@ -411,7 +354,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) navigate(tabHref(next))
if (next) navigateTab(next)
},
},
{
@ -428,7 +371,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) navigate(tabHref(next))
if (next) navigateTab(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
@ -443,7 +386,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigate(tabHref(tab))
if (tab) navigateTab(tab)
},
}
}),
@ -482,10 +425,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)}
<TabNavItem
href={tabHref(tab)}
server={tab.server}
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => navigateTab(tab)}
onClose={() => tabsStoreActions.removeTab(i())}
active={currentTab() === tab}
activeServer={tab.server === server.key}
/>
</>
)}
@ -509,7 +455,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsStore.at(-1) ? tabHref(tabsStore.at(-1)!) : "/")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
else navigate("/")
}}
/>
</Show>
<div class="min-w-0 flex-1" />
@ -736,11 +686,14 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
function TabNavItem(props: {
href: string
server: ServerConnection.Key
directory: string
sessionId?: string
hideClose?: boolean
onClose: () => void
onNavigate: () => void
active?: boolean
activeServer: boolean
}) {
const closeTab = (event: MouseEvent) => {
event.preventDefault()
@ -748,17 +701,23 @@ function TabNavItem(props: {
props.onClose()
}
const global = useGlobal()
const serverCtx = global.createServerCtx(global.servers.default())
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.createServerCtx(conn)
})
const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory))
const [session] = createResource(
() => {
const dirSyncCtx = serverCtx.sync.createDirSyncContext(props.directory)
return props.sessionId ? ([props.sessionId, dirSyncCtx] as const) : undefined
const ctx = dirSyncCtx()
if (!ctx || !props.sessionId) return
return [props.sessionId, ctx] as const
},
async ([sessionId, dirSyncCtx]) => {
await dirSyncCtx.session.sync(sessionId).catch(() => {})
return dirSyncCtx.session.get(sessionId)
},
{ initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined },
)
return (
@ -770,18 +729,26 @@ function TabNavItem(props: {
closeTab(event)
}}
>
<Show when={session()}>
<Show when={session.latest}>
{(session) => {
const layout = useLayout()
const project = createMemo(() => projectForSession(session(), layout.projects.list()))
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
return (
<a
href={props.href}
onClick={(event) => {
event.preventDefault()
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<span data-slot="project-avatar-slot">
<ProjectTabAvatar project={project()} directory={props.directory} sessionId={session().id} />
<ProjectTabAvatar
project={project()}
directory={props.directory}
sessionId={session().id}
activeServer={props.activeServer}
/>
</span>
<span class="min-w-0 flex-1">{session().title}</span>
</a>
@ -809,10 +776,15 @@ function TabNavItem(props: {
)
}
function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) {
function ProjectTabAvatar(props: {
project?: LocalProject
directory: string
sessionId: string
activeServer: boolean
}) {
const directory = () => props.directory
const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId)
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}