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
parent 7ae856a9e9
commit 7f33576f46
62 changed files with 1523 additions and 841 deletions
+71 -99
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 })}