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:
Shantur Rathore
2026-04-02 23:00:17 +01:00
committed by GitHub
parent 19a4c3df16
commit d0a0325d7e
47 changed files with 2139 additions and 218 deletions

View File

@@ -27,6 +27,7 @@ type HomeTab = "local" | "servers"
interface FolderSelectionViewProps {
onSelectFolder: (folder: string, binaryPath?: string) => void
onOpenSidecar?: () => void
isLoading?: boolean
onClose?: () => void
}
@@ -845,32 +846,43 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<div class="panel-body flex flex-col gap-3">
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
onClick={() => void handleBrowse()}
disabled={props.isLoading}
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
onMouseEnter={() => setFocusMode("new")}
>
<div class="flex items-center gap-2">
<FolderPlus class="w-4 h-4" />
<span>
{props.isLoading
? t("folderSelection.browse.buttonOpening")
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
<button
type="button"
onClick={() => props.onOpenSidecar?.()}
class="button-primary mt-3 w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<MonitorUp class="w-4 h-4" />
<span>{t("folderSelection.sidecars.button")}</span>
</div>
</button>
<button
onClick={openServerDialog}
class="button-primary w-full flex items-center justify-center text-sm"
>
<div class="flex items-center gap-2">
<Globe class="w-4 h-4" />
<span>{t("folderSelection.actions.connectButton")}</span>
</div>
</button>
</div>
{/* OpenCode settings section */}

View File

@@ -1,6 +1,5 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Dynamic } from "solid-js/web"
import type { Instance } from "../types/instance"
import InstanceTab from "./instance-tab"
import KeyboardHint from "./keyboard-hint"
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
@@ -9,12 +8,13 @@ import { useI18n } from "../lib/i18n"
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
import { useConfig } from "../stores/preferences"
import { openSettings } from "../stores/settings-screen"
import type { AppTabRecord } from "../stores/app-tabs"
interface InstanceTabsProps {
instances: Map<string, Instance>
activeInstanceId: string | null
onSelect: (instanceId: string) => void
onClose: (instanceId: string) => void
tabs: AppTabRecord[]
activeTabId: string | null
onSelect: (tabId: string) => void
onClose: (tabId: string) => void
onNew: () => void
}
@@ -42,15 +42,25 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
<div class="tab-scroll">
<div class="tab-strip">
<div class="tab-strip-tabs">
<For each={Array.from(props.instances.entries())}>
{([id, instance]) => (
<InstanceTab
instance={instance}
active={id === props.activeInstanceId}
onSelect={() => props.onSelect(id)}
onClose={() => props.onClose(id)}
/>
)}
<For each={props.tabs}>
{(tab) =>
tab.kind === "instance" ? (
<InstanceTab
instance={tab.instance}
active={tab.id === props.activeTabId}
onSelect={() => props.onSelect(tab.id)}
onClose={() => props.onClose(tab.id)}
/>
) : (
<div class={`tab-pill ${tab.id === props.activeTabId ? "tab-pill-active" : ""}`}>
<button class="tab-pill-button" onClick={() => props.onSelect(tab.id)}>
<span class="truncate max-w-[180px]">{tab.sidecarTab.name}</span>
</button>
<button class="tab-pill-close" onClick={() => props.onClose(tab.id)} aria-label={tab.sidecarTab.name}>
×
</button>
</div>
)}
</For>
<button
class="new-tab-button"
@@ -62,7 +72,7 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
</button>
</div>
<div class="tab-strip-spacer" />
<Show when={Array.from(props.instances.entries()).length > 1}>
<Show when={props.tabs.length > 1}>
<div class="tab-shortcuts">
<KeyboardHint
shortcuts={[keyboardRegistry.get("instance-prev")!, keyboardRegistry.get("instance-next")!].filter(

View File

@@ -9,7 +9,7 @@ import type { MessageRecord } from "../stores/message-v2/types"
import { messageStoreBus } from "../stores/message-v2/bus"
import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances"
import { selectInstanceTab } from "../stores/app-tabs"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
@@ -130,7 +130,7 @@ function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string
}
function navigateToTaskSession(location: TaskSessionLocation) {
setActiveInstanceId(location.instanceId)
selectInstanceTab(location.instanceId)
const parentToActivate = location.parentId ?? location.sessionId
setActiveParentSession(location.instanceId, parentToActivate)
if (location.parentId) {

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, Globe, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
@@ -14,6 +14,7 @@ import { NotificationsSettingsSection } from "./settings/notifications-settings-
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
@@ -23,6 +24,7 @@ export const SettingsScreen: Component = () => {
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
@@ -34,6 +36,8 @@ export const SettingsScreen: Component = () => {
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "sidecars":
return <SideCarsSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":

View File

@@ -0,0 +1,201 @@
import { createMemo, createSignal, For, Show, onMount, type Component } from "solid-js"
import { Globe, Loader2, Plus, Trash2 } from "lucide-solid"
import { useI18n } from "../../lib/i18n"
import { serverApi } from "../../lib/api-client"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../../stores/sidecars"
function deriveSidecarId(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-{2,}/g, "-")
.replace(/^-|-$/g, "")
}
export const SideCarsSettingsSection: Component = () => {
const { t } = useI18n()
const [name, setName] = createSignal("")
const [port, setPort] = createSignal("3000")
const [insecure, setInsecure] = createSignal(false)
const [prefixMode, setPrefixMode] = createSignal<"strip" | "preserve">("strip")
const [busyId, setBusyId] = createSignal<string | null>(null)
const [creating, setCreating] = createSignal(false)
const [formError, setFormError] = createSignal<string | null>(null)
const [actionError, setActionError] = createSignal<string | null>(null)
onMount(() => {
void ensureSidecarsLoaded()
})
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
const derivedId = createMemo(() => deriveSidecarId(name()) || "your-sidecar")
async function handleCreate() {
const trimmedName = name().trim()
const nextPort = Number(port())
if (!trimmedName || !Number.isInteger(nextPort) || nextPort <= 0 || nextPort > 65535) {
setFormError(t("sidecars.form.validation"))
return
}
setCreating(true)
setFormError(null)
try {
await serverApi.createSidecar({
kind: "port",
name: trimmedName,
port: nextPort,
insecure: insecure(),
prefixMode: prefixMode(),
})
setName("")
setPort("3000")
setInsecure(false)
setPrefixMode("strip")
} catch (error) {
setFormError(error instanceof Error ? error.message : String(error))
} finally {
setCreating(false)
}
}
async function handleDelete(id: string) {
setBusyId(id)
setActionError(null)
try {
await serverApi.deleteSidecar(id)
} catch (error) {
setActionError(error instanceof Error ? error.message : String(error))
} finally {
setBusyId(null)
}
}
return (
<div class="settings-section-stack">
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Globe class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.section.sidecars.title")}</h3>
<p class="settings-card-subtitle">{t("settings.section.sidecars.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-card-content">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.name")}</div>
<div class="settings-toggle-caption">{t("sidecars.basePath")}: <code>/sidecars/{derivedId()}</code></div>
</div>
<input
class="selector-input w-full max-w-xs"
value={name()}
onInput={(event) => {
setFormError(null)
setName(event.currentTarget.value)
}}
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.port")}</div>
<div class="settings-toggle-caption">127.0.0.1</div>
</div>
<input
class="selector-input w-full max-w-xs"
value={port()}
onInput={(event) => {
setFormError(null)
setPort(event.currentTarget.value)
}}
inputMode="numeric"
/>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.protocol")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.protocol.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={insecure() ? "http" : "https"} onChange={(event) => setInsecure(event.currentTarget.value === "http") }>
<option value="https">{t("sidecars.form.protocol.https")}</option>
<option value="http">{t("sidecars.form.protocol.http")}</option>
</select>
</div>
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("sidecars.form.prefixMode")}</div>
<div class="settings-toggle-caption">{t("sidecars.form.prefixMode.help")}</div>
</div>
<select class="selector-input w-full max-w-xs" value={prefixMode()} onChange={(event) => setPrefixMode(event.currentTarget.value as "strip" | "preserve") }>
<option value="strip">{t("sidecars.form.prefixMode.strip")}</option>
<option value="preserve">{t("sidecars.form.prefixMode.preserve")}</option>
</select>
</div>
<Show when={formError()}>
<div class="text-sm text-red-500">{formError()}</div>
</Show>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-primary" disabled={creating()} onClick={() => void handleCreate()}>
<Show when={creating()} fallback={<Plus class="w-4 h-4" />}>
<Loader2 class="w-4 h-4 animate-spin" />
</Show>
<span>{t("sidecars.form.add")}</span>
</button>
</div>
</div>
</div>
<div class="settings-card">
<div class="settings-card-header">
<div>
<h3 class="settings-card-title">{t("sidecars.settings.listTitle")}</h3>
<p class="settings-card-subtitle">{t("sidecars.settings.listSubtitle")}</p>
</div>
</div>
<div class="settings-card-content">
<Show when={actionError()}>
<div class="text-sm text-red-500">{actionError()}</div>
</Show>
<Show when={!sidecarsLoading()} fallback={<div class="settings-card-message">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="settings-card-message">{t("sidecars.settings.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{sidecar.name}</div>
<div class="settings-toggle-caption">
{t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="settings-toggle-caption">
{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code> · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-secondary min-w-[4.5rem] text-right">{t(`sidecars.status.${sidecar.status}`)}</span>
<button type="button" class="selector-button selector-button-secondary" disabled={busyId() === sidecar.id} onClick={() => void handleDelete(sidecar.id)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Dialog } from "@kobalte/core/dialog"
import { For, Show, createEffect, createMemo, type Component } from "solid-js"
import { Globe, Square } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { ensureSidecarsLoaded, sidecars, sidecarsLoading } from "../stores/sidecars"
interface SideCarPickerDialogProps {
open: boolean
onClose: () => void
onOpenSidecar: (sidecarId: string) => void | Promise<void>
}
export const SideCarPickerDialog: Component<SideCarPickerDialogProps> = (props) => {
const { t } = useI18n()
const orderedSidecars = createMemo(() => Array.from(sidecars().values()).sort((a, b) => a.name.localeCompare(b.name)))
createEffect(() => {
if (props.open) {
void ensureSidecarsLoaded()
}
})
return (
<Dialog open={props.open} onOpenChange={(open) => !open && props.onClose()}>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-2xl p-6 flex flex-col gap-4 max-h-[80vh] overflow-hidden">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">{t("sidecars.picker.title")}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
{t("sidecars.picker.subtitle")}
</Dialog.Description>
</div>
<div class="flex-1 overflow-auto flex flex-col gap-3">
<Show when={!sidecarsLoading()} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.loading")}</div>}>
<Show when={orderedSidecars().length > 0} fallback={<div class="panel panel-empty-state">{t("sidecars.picker.empty")}</div>}>
<For each={orderedSidecars()}>
{(sidecar) => (
<button
type="button"
class="panel-list-item panel-list-item-content text-left disabled:cursor-not-allowed disabled:opacity-60"
disabled={sidecar.status !== "running"}
onClick={() => void props.onOpenSidecar(sidecar.id)}
>
<div class="flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<span class="panel-empty-state-icon !w-10 !h-10">
<Globe class="w-5 h-5" />
</span>
<div class="min-w-0">
<div class="text-sm font-medium text-primary truncate">{sidecar.name}</div>
<div class="text-xs text-muted">
{t("sidecars.kind.port")} - {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port}
</div>
<div class="text-xs text-muted mt-1">{t("sidecars.basePath")}: <code>/sidecars/{sidecar.id}</code></div>
</div>
</div>
<div class="text-xs text-secondary flex items-center gap-2">
<Square class="w-4 h-4" />
<span>{t(`sidecars.status.${sidecar.status}`)}</span>
</div>
</div>
</button>
)}
</For>
</Show>
</Show>
</div>
<div class="flex justify-end">
<button type="button" class="selector-button selector-button-secondary" onClick={props.onClose}>
{t("sidecars.picker.close")}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -0,0 +1,197 @@
import { ArrowLeft, ArrowRight, RefreshCw } from "lucide-solid"
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
import type { SideCarTabRecord } from "../stores/sidecars"
import { useI18n } from "../lib/i18n"
interface SideCarViewProps {
tab: SideCarTabRecord
}
export const SideCarView: Component<SideCarViewProps> = (props) => {
const { t } = useI18n()
const [frameSrc, setFrameSrc] = createSignal(props.tab.shellUrl)
const [pathInput, setPathInput] = createSignal("/")
let iframeRef: HTMLIFrameElement | undefined
const lockedBaseLabel = createMemo(() => {
const hostLabel = props.tab.port ? `${props.tab.name}:${props.tab.port}` : props.tab.name
if (props.tab.prefixMode === "preserve") {
return `${hostLabel}${props.tab.proxyBasePath}`
}
return hostLabel
})
const getEditablePathFromUrl = (url: string): string => {
try {
const parsed = new URL(url, window.location.origin)
const basePath = props.tab.proxyBasePath
let pathname = parsed.pathname
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.slice(basePath.length) || "/"
}
if (!pathname.startsWith("/")) {
pathname = `/${pathname}`
}
return `${pathname}${parsed.search}${parsed.hash}`
} catch {
return "/"
}
}
const buildNormalizedTargetUrl = (rawInput: string): string => {
const trimmed = rawInput.trim()
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`
const parsed = new URL(withLeadingSlash || "/", window.location.origin)
const safeSegments: string[] = []
for (const segment of parsed.pathname.split("/")) {
if (!segment || segment === ".") {
continue
}
if (segment === "..") {
if (safeSegments.length > 0) {
safeSegments.pop()
}
continue
}
safeSegments.push(segment)
}
const normalizedPath = `/${safeSegments.join("/")}` || "/"
const basePath = props.tab.proxyBasePath
return `${basePath}${normalizedPath}${parsed.search}${parsed.hash}`
}
const syncPathInputFromFrame = () => {
try {
const currentHref = iframeRef?.contentWindow?.location.href
if (!currentHref) {
return
}
setPathInput(getEditablePathFromUrl(currentHref))
} catch {
setPathInput(getEditablePathFromUrl(frameSrc()))
}
}
createEffect(() => {
setFrameSrc(props.tab.shellUrl)
setPathInput(getEditablePathFromUrl(props.tab.shellUrl))
})
const handleBack = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
try {
const frameWindow = iframeRef?.contentWindow
if (!frameWindow) {
return
}
if (frameWindow.history.length <= 1) {
return
}
frameWindow.focus()
frameWindow.history.go(-1)
} catch {
// Ignore navigation errors from pages that do not expose history access.
}
}
const handleRefresh = () => {
try {
iframeRef?.contentWindow?.location.reload()
return
} catch {
// Fall back to resetting the iframe source if the frame cannot be reloaded directly.
}
setFrameSrc("about:blank")
requestAnimationFrame(() => setFrameSrc(props.tab.shellUrl))
}
const handleGo = (event?: Event) => {
event?.preventDefault()
const nextUrl = buildNormalizedTargetUrl(pathInput())
setFrameSrc(nextUrl)
setPathInput(getEditablePathFromUrl(nextUrl))
}
return (
<div class="flex h-full min-h-0 w-full flex-col bg-surface">
<div
class="flex shrink-0 items-center gap-2 px-3 py-2"
style={{ "border-bottom": "1px solid var(--border-base)" }}
>
<button
type="button"
class="new-tab-button"
onClick={handleBack}
title={t("sidecars.back")}
aria-label={t("sidecars.back")}
>
<ArrowLeft class="h-4 w-4" />
</button>
<button
type="button"
class="new-tab-button"
onClick={handleRefresh}
title={t("sidecars.refresh")}
aria-label={t("sidecars.refresh")}
>
<RefreshCw class="h-4 w-4" />
</button>
<div
class="shrink-0 rounded-md px-3 py-1.5 text-sm"
style={{
background: "var(--surface-secondary)",
color: "var(--text-secondary)",
border: "1px solid var(--border-base)",
}}
>
{lockedBaseLabel()}
</div>
<form class="flex min-w-0 flex-1 items-center gap-2" onSubmit={(event) => handleGo(event)}>
<input
type="text"
class="min-w-0 flex-1 rounded-md px-3 py-1.5 text-sm outline-none"
style={{
background: "var(--surface-secondary)",
color: "var(--text-primary)",
border: "1px solid var(--border-base)",
}}
value={pathInput()}
onInput={(event) => setPathInput(event.currentTarget.value)}
spellcheck={false}
autocomplete="off"
autocorrect="off"
autocapitalize="off"
aria-label={t("sidecars.path")}
/>
<button
type="submit"
class="new-tab-button"
title={t("sidecars.go")}
aria-label={t("sidecars.go")}
>
<ArrowRight class="h-4 w-4" />
</button>
</form>
</div>
<iframe
ref={iframeRef}
src={frameSrc()}
title={props.tab.name}
class="min-h-0 flex-1 w-full border-0 bg-surface"
referrerPolicy="same-origin"
onLoad={syncPathInputFromFrame}
/>
</div>
)
}