feat(sidecars): add proxied sidecar tabs (#279)
## Summary - add SideCar support across the server and UI, including proxied tabs, picker/settings flows, and websocket-aware proxying - unify top-level tab handling so workspace instances and SideCars share the same tab model and navigation flows - limit SideCars to port-based services only, removing server-managed process control from the final API and UI --------- Co-authored-by: Shantur <shantur@Mac.home> Co-authored-by: Shantur <shantur@Shanturs-MacBook-Pro-M5.local>
This commit is contained in:
172
packages/ui/src/stores/app-tabs.ts
Normal file
172
packages/ui/src/stores/app-tabs.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import type { Instance } from "../types/instance"
|
||||
import { activeInstanceId, instances, setActiveInstanceId } from "./instances"
|
||||
import { activeSidecarToken, setActiveSidecarToken, sidecarTabs, type SideCarTabRecord } from "./sidecars"
|
||||
|
||||
export interface InstanceAppTab {
|
||||
id: string
|
||||
kind: "instance"
|
||||
instance: Instance
|
||||
}
|
||||
|
||||
export interface SideCarAppTab {
|
||||
id: string
|
||||
kind: "sidecar"
|
||||
sidecarTab: SideCarTabRecord
|
||||
}
|
||||
|
||||
export type AppTabRecord = InstanceAppTab | SideCarAppTab
|
||||
|
||||
function getInstanceAppTabId(instanceId: string): string {
|
||||
return `instance:${instanceId}`
|
||||
}
|
||||
|
||||
function getSidecarAppTabId(token: string): string {
|
||||
return `sidecar:${token}`
|
||||
}
|
||||
|
||||
function getAdjacentAppTabId(tabId: string): string | null {
|
||||
const tabs = appTabs()
|
||||
const index = tabs.findIndex((tab) => tab.id === tabId)
|
||||
if (index < 0) return activeAppTabId()
|
||||
return tabs[index - 1]?.id ?? tabs[index + 1]?.id ?? null
|
||||
}
|
||||
|
||||
function getPreferredTabId(): string | null {
|
||||
const sidecarToken = activeSidecarToken()
|
||||
if (sidecarToken) {
|
||||
return getSidecarAppTabId(sidecarToken)
|
||||
}
|
||||
|
||||
const instanceId = activeInstanceId()
|
||||
if (instanceId) {
|
||||
return getInstanceAppTabId(instanceId)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const [activeAppTabId, setActiveAppTabId] = createSignal<string | null>(null)
|
||||
const [tabOrder, setTabOrder] = createSignal<string[]>([])
|
||||
|
||||
function rememberTabOrder(tabId: string) {
|
||||
setTabOrder((prev) => (prev.includes(tabId) ? prev : [...prev, tabId]))
|
||||
}
|
||||
|
||||
const appTabs = createMemo<AppTabRecord[]>(() => {
|
||||
const currentTabs = [
|
||||
...Array.from(instances().values()).map((instance) => ({
|
||||
id: getInstanceAppTabId(instance.id),
|
||||
kind: "instance" as const,
|
||||
instance,
|
||||
})),
|
||||
...sidecarTabs().map((sidecarTab) => ({
|
||||
id: getSidecarAppTabId(sidecarTab.token),
|
||||
kind: "sidecar" as const,
|
||||
sidecarTab,
|
||||
})),
|
||||
]
|
||||
|
||||
const tabsById = new Map(currentTabs.map((tab) => [tab.id, tab]))
|
||||
const orderedIds = tabOrder().filter((tabId) => tabsById.has(tabId))
|
||||
const missingIds = currentTabs.map((tab) => tab.id).filter((tabId) => !orderedIds.includes(tabId))
|
||||
|
||||
return [...orderedIds, ...missingIds].map((tabId) => tabsById.get(tabId)!).filter(Boolean)
|
||||
})
|
||||
|
||||
const activeAppTab = createMemo(() => appTabs().find((tab) => tab.id === activeAppTabId()) ?? null)
|
||||
|
||||
function getAppTabById(tabId: string | null): AppTabRecord | null {
|
||||
if (!tabId) return null
|
||||
return appTabs().find((tab) => tab.id === tabId) ?? null
|
||||
}
|
||||
|
||||
function selectAppTab(tabId: string | null) {
|
||||
if (!tabId) {
|
||||
setActiveAppTabId(null)
|
||||
setActiveSidecarToken(null)
|
||||
return
|
||||
}
|
||||
|
||||
const tab = appTabs().find((entry) => entry.id === tabId)
|
||||
if (!tab) return
|
||||
|
||||
rememberTabOrder(tab.id)
|
||||
setActiveAppTabId(tab.id)
|
||||
|
||||
if (tab.kind === "instance") {
|
||||
setActiveSidecarToken(null)
|
||||
setActiveInstanceId(tab.instance.id)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveInstanceId(null)
|
||||
setActiveSidecarToken(tab.sidecarTab.token)
|
||||
}
|
||||
|
||||
function selectInstanceTab(instanceId: string) {
|
||||
selectAppTab(getInstanceAppTabId(instanceId))
|
||||
}
|
||||
|
||||
function selectSidecarTab(token: string) {
|
||||
selectAppTab(getSidecarAppTabId(token))
|
||||
}
|
||||
|
||||
function selectNextAppTab() {
|
||||
const tabs = appTabs()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
const current = tabs.findIndex((tab) => tab.id === activeAppTabId())
|
||||
const nextIndex = current < 0 ? 0 : (current + 1) % tabs.length
|
||||
const nextTab = tabs[nextIndex]
|
||||
if (nextTab) selectAppTab(nextTab.id)
|
||||
}
|
||||
|
||||
function selectPreviousAppTab() {
|
||||
const tabs = appTabs()
|
||||
if (tabs.length <= 1) return
|
||||
|
||||
const current = tabs.findIndex((tab) => tab.id === activeAppTabId())
|
||||
const previousIndex = current <= 0 ? tabs.length - 1 : current - 1
|
||||
const previousTab = tabs[previousIndex]
|
||||
if (previousTab) selectAppTab(previousTab.id)
|
||||
}
|
||||
|
||||
function selectAppTabByIndex(index: number) {
|
||||
const tab = appTabs()[index]
|
||||
if (tab) selectAppTab(tab.id)
|
||||
}
|
||||
|
||||
function ensureActiveAppTab(preferredTabId?: string | null) {
|
||||
const tabs = appTabs()
|
||||
const current = activeAppTabId()
|
||||
|
||||
if (current && tabs.some((tab) => tab.id === current)) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidateId = preferredTabId ?? getPreferredTabId()
|
||||
if (candidateId && tabs.some((tab) => tab.id === candidateId)) {
|
||||
selectAppTab(candidateId)
|
||||
return
|
||||
}
|
||||
|
||||
selectAppTab(tabs[0]?.id ?? null)
|
||||
}
|
||||
|
||||
export {
|
||||
activeAppTabId,
|
||||
activeAppTab,
|
||||
appTabs,
|
||||
ensureActiveAppTab,
|
||||
getAdjacentAppTabId,
|
||||
getAppTabById,
|
||||
getInstanceAppTabId,
|
||||
getSidecarAppTabId,
|
||||
selectAppTab,
|
||||
selectAppTabByIndex,
|
||||
selectInstanceTab,
|
||||
selectNextAppTab,
|
||||
selectPreviousAppTab,
|
||||
selectSidecarTab,
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { clearCacheForInstance } from "../lib/global-cache"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { mergeInstanceMetadata, clearInstanceMetadata } from "./instance-metadata"
|
||||
import { showWorkspaceLaunchError } from "./launch-errors"
|
||||
import { activeSidecarToken } from "./sidecars"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
@@ -109,6 +110,8 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
||||
}
|
||||
|
||||
function ensureActiveInstanceSelected(): void {
|
||||
if (activeSidecarToken()) return
|
||||
|
||||
const current = activeInstanceId()
|
||||
const instanceMap = instances()
|
||||
if (current && instanceMap.has(current)) return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
|
||||
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars"
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||
|
||||
149
packages/ui/src/stores/sidecars.ts
Normal file
149
packages/ui/src/stores/sidecars.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { tGlobal } from "../lib/i18n"
|
||||
import { serverEvents } from "../lib/server-events"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import type { SideCar } from "../../../server/src/api-types"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
export interface SideCarTabRecord {
|
||||
token: string
|
||||
sidecarId: string
|
||||
name: string
|
||||
port?: number
|
||||
prefixMode: SideCar["prefixMode"]
|
||||
proxyBasePath: string
|
||||
shellUrl: string
|
||||
}
|
||||
|
||||
function buildSidecarShellUrl(sidecarId: string): string {
|
||||
return `/sidecars/${encodeURIComponent(sidecarId)}/`
|
||||
}
|
||||
|
||||
const [sidecars, setSidecars] = createSignal<Map<string, SideCar>>(new Map())
|
||||
const [sidecarTabs, setSidecarTabs] = createSignal<SideCarTabRecord[]>([])
|
||||
const [activeSidecarToken, setActiveSidecarToken] = createSignal<string | null>(null)
|
||||
const [sidecarsLoading, setSidecarsLoading] = createSignal(false)
|
||||
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function ensureSidecarsLoaded() {
|
||||
if (loadPromise) return loadPromise
|
||||
setSidecarsLoading(true)
|
||||
loadPromise = serverApi.fetchSidecars()
|
||||
.then((result) => {
|
||||
setSidecars(new Map(result.sidecars.map((sidecar) => [sidecar.id, sidecar])))
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("Failed to load SideCars", error)
|
||||
})
|
||||
.finally(() => {
|
||||
setSidecarsLoading(false)
|
||||
loadPromise = null
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
function upsertSidecar(sidecar: SideCar) {
|
||||
setSidecars((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(sidecar.id, sidecar)
|
||||
return next
|
||||
})
|
||||
|
||||
setSidecarTabs((prev) =>
|
||||
prev.map((tab) =>
|
||||
tab.sidecarId === sidecar.id
|
||||
? {
|
||||
...tab,
|
||||
name: sidecar.name,
|
||||
port: sidecar.port,
|
||||
prefixMode: sidecar.prefixMode,
|
||||
proxyBasePath: buildSidecarShellUrl(sidecar.id).replace(/\/$/, ""),
|
||||
shellUrl: buildSidecarShellUrl(sidecar.id),
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function removeSidecar(sidecarId: string) {
|
||||
setSidecars((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(sidecarId)
|
||||
return next
|
||||
})
|
||||
|
||||
setSidecarTabs((prev) => {
|
||||
const next = prev.filter((tab) => tab.sidecarId !== sidecarId)
|
||||
if (!next.some((tab) => tab.token === activeSidecarToken())) {
|
||||
setActiveSidecarToken(next[0]?.token ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
serverEvents.on("sidecar.updated", (event) => {
|
||||
if (event.type !== "sidecar.updated") return
|
||||
upsertSidecar(event.sidecar)
|
||||
})
|
||||
|
||||
serverEvents.on("sidecar.removed", (event) => {
|
||||
if (event.type !== "sidecar.removed") return
|
||||
removeSidecar(event.sidecarId)
|
||||
})
|
||||
|
||||
async function openSidecarTab(sidecarId: string) {
|
||||
await ensureSidecarsLoaded()
|
||||
|
||||
const sidecar = sidecars().get(sidecarId)
|
||||
if (!sidecar) {
|
||||
throw new Error(tGlobal("sidecars.open.notFound"))
|
||||
}
|
||||
if (sidecar.status !== "running") {
|
||||
throw new Error(tGlobal("sidecars.open.notRunning"))
|
||||
}
|
||||
|
||||
const token = `${sidecarId}:${Date.now().toString(36)}`
|
||||
const nextTab: SideCarTabRecord = {
|
||||
token,
|
||||
sidecarId,
|
||||
name: sidecar.name,
|
||||
port: sidecar.port,
|
||||
prefixMode: sidecar.prefixMode,
|
||||
proxyBasePath: buildSidecarShellUrl(sidecarId).replace(/\/$/, ""),
|
||||
shellUrl: buildSidecarShellUrl(sidecarId),
|
||||
}
|
||||
|
||||
setSidecarTabs((prev) => [...prev, nextTab])
|
||||
setActiveSidecarToken(nextTab.token)
|
||||
return nextTab
|
||||
}
|
||||
|
||||
function closeSidecarTab(token: string) {
|
||||
setSidecarTabs((prev) => {
|
||||
const index = prev.findIndex((tab) => tab.token === token)
|
||||
if (index < 0) return prev
|
||||
const next = prev.filter((tab) => tab.token !== token)
|
||||
if (activeSidecarToken() === token) {
|
||||
const fallback = next[index - 1] ?? next[index] ?? null
|
||||
setActiveSidecarToken(fallback?.token ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const activeSidecarTab = createMemo(() => sidecarTabs().find((tab) => tab.token === activeSidecarToken()) ?? null)
|
||||
|
||||
export {
|
||||
sidecars,
|
||||
sidecarTabs,
|
||||
activeSidecarToken,
|
||||
activeSidecarTab,
|
||||
sidecarsLoading,
|
||||
setActiveSidecarToken,
|
||||
ensureSidecarsLoaded,
|
||||
openSidecarTab,
|
||||
closeSidecarTab,
|
||||
}
|
||||
Reference in New Issue
Block a user