From d0a0325d7e3ca8fc99c735456694b1d0e225da8f Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 2 Apr 2026 23:00:17 +0100 Subject: [PATCH] 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 Co-authored-by: Shantur --- packages/electron-app/electron/main/main.ts | 27 +- packages/server/src/api-types.ts | 22 ++ packages/server/src/auth/manager.ts | 7 +- packages/server/src/events/bus.ts | 4 + packages/server/src/index.ts | 14 + packages/server/src/server/http-server.ts | 354 +++++++++++++++++- packages/server/src/server/routes/sidecars.ts | 56 +++ packages/server/src/sidecars/manager.ts | 256 +++++++++++++ packages/ui/src/App.tsx | 167 ++++++--- .../src/components/folder-selection-view.tsx | 62 +-- packages/ui/src/components/instance-tabs.tsx | 40 +- packages/ui/src/components/message-block.tsx | 4 +- .../ui/src/components/settings-screen.tsx | 6 +- .../settings/sidecars-settings-section.tsx | 201 ++++++++++ .../src/components/sidecar-picker-dialog.tsx | 82 ++++ packages/ui/src/components/sidecar-view.tsx | 197 ++++++++++ packages/ui/src/lib/api-client.ts | 30 +- .../ui/src/lib/hooks/use-app-lifecycle.ts | 3 +- packages/ui/src/lib/hooks/use-commands.ts | 24 +- .../ui/src/lib/i18n/messages/en/commands.ts | 18 +- .../lib/i18n/messages/en/folderSelection.ts | 1 + .../ui/src/lib/i18n/messages/en/settings.ts | 36 ++ .../ui/src/lib/i18n/messages/es/commands.ts | 18 +- .../lib/i18n/messages/es/folderSelection.ts | 19 +- .../ui/src/lib/i18n/messages/es/settings.ts | 36 ++ .../ui/src/lib/i18n/messages/fr/commands.ts | 18 +- .../lib/i18n/messages/fr/folderSelection.ts | 9 +- .../ui/src/lib/i18n/messages/fr/settings.ts | 36 ++ .../ui/src/lib/i18n/messages/he/commands.ts | 18 +- .../lib/i18n/messages/he/folderSelection.ts | 1 + .../ui/src/lib/i18n/messages/he/settings.ts | 36 ++ .../ui/src/lib/i18n/messages/ja/commands.ts | 18 +- .../lib/i18n/messages/ja/folderSelection.ts | 1 + .../ui/src/lib/i18n/messages/ja/settings.ts | 36 ++ .../ui/src/lib/i18n/messages/ru/commands.ts | 18 +- .../lib/i18n/messages/ru/folderSelection.ts | 1 + .../ui/src/lib/i18n/messages/ru/settings.ts | 36 ++ .../src/lib/i18n/messages/zh-Hans/commands.ts | 18 +- .../i18n/messages/zh-Hans/folderSelection.ts | 1 + .../src/lib/i18n/messages/zh-Hans/settings.ts | 36 ++ packages/ui/src/lib/keyboard.ts | 16 +- packages/ui/src/lib/shortcuts/navigation.ts | 23 +- packages/ui/src/stores/app-tabs.ts | 172 +++++++++ packages/ui/src/stores/instances.ts | 3 + packages/ui/src/stores/settings-screen.ts | 2 +- packages/ui/src/stores/sidecars.ts | 149 ++++++++ packages/ui/src/styles/panels/tabs.css | 25 ++ 47 files changed, 2139 insertions(+), 218 deletions(-) create mode 100644 packages/server/src/server/routes/sidecars.ts create mode 100644 packages/server/src/sidecars/manager.ts create mode 100644 packages/ui/src/components/settings/sidecars-settings-section.tsx create mode 100644 packages/ui/src/components/sidecar-picker-dialog.tsx create mode 100644 packages/ui/src/components/sidecar-view.tsx create mode 100644 packages/ui/src/stores/app-tabs.ts create mode 100644 packages/ui/src/stores/sidecars.ts diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 0ff28229..eeee81e4 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,7 +1,7 @@ import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import http from "node:http" import https from "node:https" -import { existsSync } from "fs" +import { existsSync, mkdirSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" import { createApplicationMenu } from "./menu" @@ -14,6 +14,31 @@ const mainDirname = dirname(mainFilename) const isMac = process.platform === "darwin" +function configureDevStoragePaths() { + if (app.isPackaged) { + return + } + + const appName = "CodeNomad" + + try { + app.setName(appName) + + const userDataPath = join(app.getPath("appData"), appName) + const sessionDataPath = join(userDataPath, "session-data") + + mkdirSync(userDataPath, { recursive: true }) + mkdirSync(sessionDataPath, { recursive: true }) + + app.setPath("userData", userDataPath) + app.setPath("sessionData", sessionDataPath) + } catch (error) { + console.warn("[cli] failed to configure dev storage paths", error) + } +} + +configureDevStoragePaths() + const cliManager = new CliProcessManager() let mainWindow: BrowserWindow | null = null let currentCliUrl: string | null = null diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index f5114ae0..b54676a6 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -170,6 +170,24 @@ export interface InstanceStreamEvent { [key: string]: unknown } +export type SideCarKind = "port" + +export type SideCarPrefixMode = "strip" | "preserve" + +export type SideCarStatus = "running" | "stopped" + +export interface SideCar { + id: string + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + status: SideCarStatus + createdAt: string + updatedAt: string +} + export interface BinaryRecord { id: string path: string @@ -276,6 +294,8 @@ export type WorkspaceEventType = | "workspace.error" | "workspace.stopped" | "workspace.log" + | "sidecar.updated" + | "sidecar.removed" | "storage.configChanged" | "storage.stateChanged" | "instance.dataChanged" @@ -288,6 +308,8 @@ export type WorkspaceEventPayload = | { type: "workspace.error"; workspace: WorkspaceDescriptor } | { type: "workspace.stopped"; workspaceId: string } | { type: "workspace.log"; entry: WorkspaceLogEntry } + | { type: "sidecar.updated"; sidecar: SideCar } + | { type: "sidecar.removed"; sidecarId: string } | { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket } | { type: "instance.dataChanged"; instanceId: string; data: InstanceData } diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index f12b8761..4e234ace 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -104,13 +104,18 @@ export class AuthManager { } getSessionFromRequest(request: FastifyRequest): { username: string; sessionId: string } | null { + return this.getSessionFromHeaders(request.headers) + } + + getSessionFromHeaders(headers: { cookie?: string | string[] | undefined }): { username: string; sessionId: string } | null { if (!this.authEnabled) { // When auth is disabled, treat all requests as authenticated. // We still return a stable username so callers can display it. return { username: this.init.username, sessionId: "auth-disabled" } } - const cookies = parseCookies(request.headers.cookie) + const cookieHeader = Array.isArray(headers.cookie) ? headers.cookie.join("; ") : headers.cookie + const cookies = parseCookies(cookieHeader) const sessionId = cookies[this.cookieName] const session = this.sessionManager.getSession(sessionId) if (!session) return null diff --git a/packages/server/src/events/bus.ts b/packages/server/src/events/bus.ts index 7673f00a..fd1e3ce6 100644 --- a/packages/server/src/events/bus.ts +++ b/packages/server/src/events/bus.ts @@ -24,6 +24,8 @@ export class EventBus extends EventEmitter { this.on("workspace.error", handler) this.on("workspace.stopped", handler) this.on("workspace.log", handler) + this.on("sidecar.updated", handler) + this.on("sidecar.removed", handler) this.on("storage.configChanged", handler) this.on("storage.stateChanged", handler) this.on("instance.dataChanged", handler) @@ -35,6 +37,8 @@ export class EventBus extends EventEmitter { this.off("workspace.error", handler) this.off("workspace.stopped", handler) this.off("workspace.log", handler) + this.off("sidecar.updated", handler) + this.off("sidecar.removed", handler) this.off("storage.configChanged", handler) this.off("storage.stateChanged", handler) this.off("instance.dataChanged", handler) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 20179023..8f983994 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -24,6 +24,7 @@ import { resolveHttpsOptions } from "./server/tls" import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { SpeechService } from "./speech/service" +import { SideCarManager } from "./sidecars/manager" const require = createRequire(import.meta.url) @@ -315,6 +316,11 @@ async function main() { const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const instanceStore = new InstanceStore(configLocation.instancesDir) const speechService = new SpeechService(settings, logger.child({ component: "speech" })) + const sidecarManager = new SideCarManager({ + settings, + eventBus, + logger: logger.child({ component: "sidecars" }), + }) const instanceEventBridge = new InstanceEventBridge({ workspaceManager, eventBus, @@ -400,6 +406,7 @@ async function main() { serverMeta, instanceStore, speechService, + sidecarManager, authManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: uiResolution.uiDevServerUrl, @@ -421,6 +428,7 @@ async function main() { serverMeta, instanceStore, speechService, + sidecarManager, authManager, uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR, uiDevServerUrl: undefined, @@ -520,6 +528,12 @@ async function main() { logger.warn({ err: error }, "Instance event bridge shutdown failed") } + try { + await sidecarManager.shutdown() + } catch (error) { + logger.error({ err: error }, "SideCar manager shutdown failed") + } + try { await workspaceManager.shutdown() logger.info("Workspace manager shutdown complete") diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 9aaf3816..114ecf1c 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -3,7 +3,9 @@ import cors from "@fastify/cors" import fastifyStatic from "@fastify/static" import replyFrom from "@fastify/reply-from" import fs from "fs" +import { connect as connectTcp, type Socket } from "net" import path from "path" +import { connect as connectTls, type TLSSocket } from "tls" import { fetch } from "undici" import type { Logger } from "../logger" import { WorkspaceManager } from "../workspaces/manager" @@ -23,6 +25,7 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes" import { registerWorktreeRoutes } from "./routes/worktrees" import { registerSpeechRoutes } from "./routes/speech" import { registerRemoteServerRoutes } from "./routes/remote-servers" +import { registerSideCarRoutes } from "./routes/sidecars" import { ServerMeta } from "../api-types" import { InstanceStore } from "../storage/instance-store" import { BackgroundProcessManager } from "../background-processes/manager" @@ -33,6 +36,7 @@ import type { SpeechService } from "../speech/service" import { ClientConnectionManager } from "../clients/connection-manager" import { PluginChannelManager } from "../plugins/channel" import { VoiceModeManager } from "../plugins/voice-mode" +import type { SideCarManager } from "../sidecars/manager" interface HttpServerDeps { bindHost: string @@ -48,6 +52,7 @@ interface HttpServerDeps { serverMeta: ServerMeta instanceStore: InstanceStore speechService: SpeechService + sidecarManager: SideCarManager authManager: AuthManager uiStaticDir: string uiDevServerUrl?: string @@ -204,7 +209,7 @@ export function createHttpServer(deps: HttpServerDeps) { const session = deps.authManager.getSessionFromRequest(request) - const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") + const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/") || pathname.startsWith("/sidecars/") if (requiresAuthForApi && !session) { // Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth. const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/) @@ -273,6 +278,13 @@ export function createHttpServer(deps: HttpServerDeps) { }) registerRemoteServerRoutes(app, { logger: apiLogger }) registerSpeechRoutes(app, { speechService: deps.speechService }) + registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager }) + registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger }) + setupSideCarWebSocketProxy(app, { + sidecarManager: deps.sidecarManager, + authManager: deps.authManager, + logger: proxyLogger, + }) registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, @@ -355,6 +367,68 @@ interface InstanceProxyDeps { logger: Logger } +interface SideCarProxyDeps { + sidecarManager: SideCarManager + logger: Logger +} + +interface SideCarWebSocketProxyDeps extends SideCarProxyDeps { + authManager: AuthManager +} + +function registerSideCarProxyRoutes(app: FastifyInstance, deps: SideCarProxyDeps) { + const proxyBaseHandler = async ( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, + ) => { + await proxySideCarRequest({ + request, + reply, + sidecarManager: deps.sidecarManager, + logger: deps.logger, + pathSuffix: "", + }) + } + + const proxyWildcardHandler = async ( + request: FastifyRequest<{ Params: { id: string; "*": string } }>, + reply: FastifyReply, + ) => { + await proxySideCarRequest({ + request, + reply, + sidecarManager: deps.sidecarManager, + logger: deps.logger, + pathSuffix: request.params["*"] ?? "", + }) + } + + app.all("/sidecars/:id", proxyBaseHandler) + app.all("/sidecars/:id/*", proxyWildcardHandler) +} + +function setupSideCarWebSocketProxy(app: FastifyInstance, deps: SideCarWebSocketProxyDeps) { + app.server.on("upgrade", (request, socket, head) => { + const rawUrl = request.url ?? "/" + const parsed = parseSideCarUpgradePath(rawUrl) + if (!parsed) { + return + } + + void proxySideCarWebSocketUpgrade({ + request, + socket: socket as Socket, + head, + sidecarId: parsed.sidecarId, + incomingPath: parsed.pathname, + search: parsed.search, + sidecarManager: deps.sidecarManager, + authManager: deps.authManager, + logger: deps.logger, + }) + }) +} + function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() @@ -839,3 +913,281 @@ function buildProxyHeaders(headers: FastifyRequest["headers"]): Record= 0 ? (args.request.raw.url ?? args.request.url ?? "").slice(queryIndex) : "" + const pathSuffix = args.pathSuffix ?? "" + const requestPath = pathSuffix ? `${args.sidecarManager.buildProxyBasePath(sidecarId)}/${pathSuffix.replace(/^\/+/, "")}` : args.sidecarManager.buildProxyBasePath(sidecarId) + const targetPath = args.sidecarManager.buildTargetPath(sidecarId, requestPath, search) + const targetOrigin = args.sidecarManager.buildTargetOrigin(sidecar) + const targetUrl = `${targetOrigin}${targetPath}` + args.logger.debug({ sidecarId: sidecar.id, targetUrl, pathname, prefixMode: sidecar.prefixMode }, "Proxying request to SideCar") + + await args.reply.from(targetUrl, { + rewriteRequestHeaders: (_originalRequest, headers) => + sanitizeSideCarProxyRequestHeaders(headers as Record, targetOrigin), + rewriteHeaders: (headers) => rewriteSideCarResponseHeaders(headers, sidecarId, targetOrigin, sidecar.prefixMode), + onError: (reply, { error }) => { + args.logger.error({ sidecarId: sidecar.id, err: error, targetUrl }, "Failed to proxy SideCar request") + if (!reply.sent) { + reply.code(502).send({ error: "SideCar proxy failed" }) + } + }, + }) +} + +function parseSideCarUpgradePath(rawUrl: string): { sidecarId: string; pathname: string; search: string } | null { + let parsed: URL + try { + parsed = new URL(rawUrl, "http://localhost") + } catch { + return null + } + + const match = parsed.pathname.match(/^\/sidecars\/([^/]+)(?:\/.*)?$/) + if (!match) { + return null + } + + try { + return { + sidecarId: decodeURIComponent(match[1] ?? ""), + pathname: parsed.pathname, + search: parsed.search, + } + } catch { + return null + } +} + +async function proxySideCarWebSocketUpgrade(args: { + request: import("http").IncomingMessage + socket: Socket + head: Buffer + sidecarId: string + incomingPath: string + search: string + sidecarManager: SideCarManager + authManager: AuthManager + logger: Logger +}) { + const { request, socket, head, sidecarId, incomingPath, search, sidecarManager, authManager, logger } = args + + if (!isWebSocketUpgradeRequest(request)) { + rejectUpgrade(socket, 400, "Bad Request") + return + } + + const session = authManager.getSessionFromHeaders(request.headers) + if (!session) { + rejectUpgrade(socket, 401, "Unauthorized") + return + } + + const sidecar = await sidecarManager.get(sidecarId) + if (!sidecar) { + rejectUpgrade(socket, 404, "Not Found") + return + } + + const targetOrigin = sidecarManager.buildTargetOrigin(sidecar) + const targetPath = sidecarManager.buildTargetPath(sidecarId, incomingPath, search) + const targetUrl = new URL(`${targetOrigin}${targetPath}`) + logger.debug({ sidecarId, targetUrl: targetUrl.toString(), prefixMode: sidecar.prefixMode }, "Proxying websocket to SideCar") + + const { socket: upstream, readyEvent } = createSideCarUpstreamSocket(targetUrl) + + const closeBoth = () => { + if (!socket.destroyed) { + socket.destroy() + } + if (!upstream.destroyed) { + upstream.destroy() + } + } + + upstream.once("error", (error) => { + logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to proxy SideCar websocket") + rejectUpgrade(socket, 502, "Bad Gateway") + if (!upstream.destroyed) { + upstream.destroy() + } + }) + + socket.once("error", (error) => { + logger.debug({ sidecarId, err: error }, "SideCar websocket client socket errored") + if (!upstream.destroyed) { + upstream.destroy() + } + }) + + upstream.once(readyEvent, () => { + try { + upstream.write(buildSideCarWebSocketRequest(request, targetUrl)) + if (head.length > 0) { + upstream.write(head) + } + upstream.pipe(socket) + socket.pipe(upstream) + } catch (error) { + logger.error({ sidecarId, err: error, targetUrl: targetUrl.toString() }, "Failed to forward SideCar websocket upgrade") + closeBoth() + } + }) + + upstream.once("close", () => { + if (!socket.destroyed) { + socket.end() + } + }) + + socket.once("close", () => { + if (!upstream.destroyed) { + upstream.end() + } + }) +} + +function createSideCarUpstreamSocket(targetUrl: URL): { socket: Socket | TLSSocket; readyEvent: "connect" | "secureConnect" } { + const port = Number(targetUrl.port || (targetUrl.protocol === "https:" ? 443 : 80)) + if (targetUrl.protocol === "https:") { + return { + socket: connectTls({ + host: targetUrl.hostname, + port, + servername: targetUrl.hostname, + }), + readyEvent: "secureConnect", + } + } + return { + socket: connectTcp(port, targetUrl.hostname), + readyEvent: "connect", + } +} + +function buildSideCarWebSocketRequest(request: import("http").IncomingMessage, targetUrl: URL): string { + const pathWithQuery = `${targetUrl.pathname}${targetUrl.search}` + const requestLine = `${request.method ?? "GET"} ${pathWithQuery} HTTP/${request.httpVersion}\r\n` + const headerLines: string[] = [] + const rawHeaders = request.rawHeaders ?? [] + const blockedHeaders = getBlockedSideCarRequestHeaders() + + for (let index = 0; index < rawHeaders.length; index += 2) { + const key = rawHeaders[index] + const value = rawHeaders[index + 1] + if (!key || value === undefined) continue + const lower = key.toLowerCase() + if (blockedHeaders.has(lower)) continue + if (lower === "origin") { + headerLines.push(`Origin: ${targetUrl.origin}\r\n`) + continue + } + headerLines.push(`${key}: ${value}\r\n`) + } + + const hostValue = targetUrl.port ? `${targetUrl.hostname}:${targetUrl.port}` : targetUrl.hostname + headerLines.push(`Host: ${hostValue}\r\n`) + headerLines.push("\r\n") + + return requestLine + headerLines.join("") +} + +function isWebSocketUpgradeRequest(request: import("http").IncomingMessage): boolean { + const upgrade = request.headers.upgrade + if (typeof upgrade !== "string" || upgrade.toLowerCase() !== "websocket") { + return false + } + const connection = request.headers.connection + const connectionValue = Array.isArray(connection) ? connection.join(",") : connection ?? "" + return connectionValue.toLowerCase().split(",").map((part) => part.trim()).includes("upgrade") +} + +function rejectUpgrade(socket: Socket, statusCode: number, statusText: string) { + if (socket.destroyed) { + return + } + socket.write(`HTTP/1.1 ${statusCode} ${statusText}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`) + socket.destroy() +} + +function rewriteSideCarResponseHeaders( + headers: Record, + sidecarId: string, + targetOrigin: string, + prefixMode: "strip" | "preserve", +) { + if (prefixMode === "preserve") { + return headers + } + + const next = { ...headers } + const locationHeader = next.location + const location = Array.isArray(locationHeader) ? locationHeader[0] : locationHeader + if (!location) { + return next + } + + const publicBase = `/sidecars/${encodeURIComponent(sidecarId)}` + + if (location.startsWith("/")) { + next.location = `${publicBase}${location}` + return next + } + + try { + const parsed = new URL(location) + if (parsed.origin === targetOrigin) { + next.location = `${publicBase}${parsed.pathname}${parsed.search}${parsed.hash}` + } + } catch { + // Relative redirects should continue to resolve against the public sidecar path. + } + + return next +} + +function sanitizeSideCarProxyRequestHeaders( + headers: Record, + targetOrigin: string, +): Record { + const blockedHeaders = getBlockedSideCarRequestHeaders() + const next: Record = {} + + for (const [key, value] of Object.entries(headers)) { + if (!value) continue + if (blockedHeaders.has(key.toLowerCase())) continue + next[key] = value + } + + next.origin = targetOrigin + return next +} + +function getBlockedSideCarRequestHeaders(): Set { + return new Set([ + "host", + "authorization", + "proxy-authorization", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + ]) +} diff --git a/packages/server/src/server/routes/sidecars.ts b/packages/server/src/server/routes/sidecars.ts new file mode 100644 index 00000000..61220377 --- /dev/null +++ b/packages/server/src/server/routes/sidecars.ts @@ -0,0 +1,56 @@ +import { FastifyInstance } from "fastify" +import { z } from "zod" +import type { SideCarManager } from "../../sidecars/manager" + +interface RouteDeps { + sidecarManager: SideCarManager +} + +const SideCarCreateSchema = z.object({ + kind: z.literal("port").default("port"), + name: z.string().trim().min(1), + port: z.number().int().min(1).max(65535), + insecure: z.boolean().default(false), + prefixMode: z.enum(["strip", "preserve"]).default("strip"), +}) + +const SideCarUpdateSchema = SideCarCreateSchema.omit({ kind: true }).partial().refine((value) => Object.keys(value).length > 0, { + message: "At least one field is required", +}) + +export function registerSideCarRoutes(app: FastifyInstance, deps: RouteDeps) { + app.get("/api/sidecars", async () => { + return { sidecars: await deps.sidecarManager.list() } + }) + + app.post("/api/sidecars", async (request, reply) => { + try { + const body = SideCarCreateSchema.parse(request.body ?? {}) + const sidecar = await deps.sidecarManager.create(body) + reply.code(201) + return sidecar + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to create SideCar" } + } + }) + + app.put<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => { + try { + const body = SideCarUpdateSchema.parse(request.body ?? {}) + return await deps.sidecarManager.update(request.params.id, body) + } catch (error) { + reply.code(400) + return { error: error instanceof Error ? error.message : "Failed to update SideCar" } + } + }) + + app.delete<{ Params: { id: string } }>("/api/sidecars/:id", async (request, reply) => { + const removed = await deps.sidecarManager.delete(request.params.id) + if (!removed) { + reply.code(404) + return { error: "SideCar not found" } + } + reply.code(204) + }) +} diff --git a/packages/server/src/sidecars/manager.ts b/packages/server/src/sidecars/manager.ts new file mode 100644 index 00000000..57593c30 --- /dev/null +++ b/packages/server/src/sidecars/manager.ts @@ -0,0 +1,256 @@ +import { connect } from "net" +import type { EventBus } from "../events/bus" +import type { Logger } from "../logger" +import type { SettingsService } from "../settings/service" +import type { SideCar, SideCarKind, SideCarPrefixMode, SideCarStatus } from "../api-types" + +interface SideCarManagerOptions { + settings: SettingsService + eventBus: EventBus + logger: Logger +} + +interface SideCarConfigRecord { + id: string + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + createdAt: string + updatedAt: string +} + +interface SideCarRuntimeRecord { + status: SideCarStatus +} + +export class SideCarManager { + private readonly configs = new Map() + private readonly runtime = new Map() + + constructor(private readonly options: SideCarManagerOptions) { + for (const record of this.loadConfiguredSideCars()) { + this.configs.set(record.id, record) + this.runtime.set(record.id, { status: "stopped" }) + } + + queueMicrotask(() => { + for (const record of this.configs.values()) { + void this.refreshPortSideCar(record.id).catch((error) => { + this.options.logger.warn({ sidecarId: record.id, err: error }, "Failed to probe sidecar port") + }) + } + }) + } + + async list(): Promise { + await this.refreshPortStatuses() + return Array.from(this.configs.values()).map((record) => this.toSideCar(record)) + } + + async get(id: string): Promise { + if (!this.configs.has(id)) return undefined + await this.refreshPortSideCar(id) + return this.toSideCar(this.requireConfig(id)) + } + + async create(input: { + kind: SideCarKind + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + }): Promise { + const normalizedName = input.name.trim() + const id = this.buildSideCarId(normalizedName) + if (this.configs.has(id)) { + throw new Error(`SideCar '${id}' already exists`) + } + + const now = new Date().toISOString() + const record: SideCarConfigRecord = { + id, + kind: input.kind, + name: normalizedName, + port: input.port, + insecure: input.insecure, + prefixMode: input.prefixMode, + createdAt: now, + updatedAt: now, + } + + this.configs.set(record.id, record) + this.runtime.set(record.id, { status: "stopped" }) + this.persistConfigs() + await this.refreshPortSideCar(record.id) + return this.toSideCar(record) + } + + async update( + id: string, + input: Partial<{ + name: string + port: number + insecure: boolean + prefixMode: SideCarPrefixMode + }>, + ): Promise { + const record = this.requireConfig(id) + + record.name = typeof input.name === "string" ? input.name.trim() : record.name + record.port = typeof input.port === "number" ? input.port : record.port + record.insecure = typeof input.insecure === "boolean" ? input.insecure : record.insecure + record.prefixMode = typeof input.prefixMode === "string" ? input.prefixMode : record.prefixMode + record.updatedAt = new Date().toISOString() + + this.persistConfigs() + await this.refreshPortSideCar(id) + return this.toSideCar(record) + } + + async delete(id: string): Promise { + const record = this.configs.get(id) + if (!record) return false + + this.configs.delete(id) + this.runtime.delete(id) + this.persistConfigs() + this.options.eventBus.publish({ type: "sidecar.removed", sidecarId: id }) + return true + } + + async shutdown() { + return + } + + buildTargetOrigin(sidecar: Pick): string { + const protocol = sidecar.insecure ? "http" : "https" + return `${protocol}://127.0.0.1:${sidecar.port}` + } + + buildProxyBasePath(id: string): string { + return `/sidecars/${encodeURIComponent(id)}` + } + + buildTargetPath(id: string, incomingPath: string, search = ""): string { + const record = this.requireConfig(id) + const publicBase = this.buildProxyBasePath(id) + const normalizedPath = incomingPath || publicBase + + if (record.prefixMode === "preserve") { + return `${normalizedPath}${search}` + } + + let stripped = normalizedPath.startsWith(publicBase) ? normalizedPath.slice(publicBase.length) : normalizedPath + if (!stripped || stripped === "/") { + stripped = "/" + } else if (!stripped.startsWith("/")) { + stripped = `/${stripped}` + } + return `${stripped}${search}` + } + + private async refreshPortStatuses() { + await Promise.all(Array.from(this.configs.values()).map((record) => this.refreshPortSideCar(record.id))) + } + + private async refreshPortSideCar(id: string) { + const record = this.configs.get(id) + if (!record) return + const isAvailable = await this.isPortAvailable(record.port) + const current = this.runtime.get(id) + const nextStatus: SideCarStatus = isAvailable ? "running" : "stopped" + if (current?.status === nextStatus) { + return + } + + this.runtime.set(id, { status: nextStatus }) + record.updatedAt = new Date().toISOString() + this.publish(id) + } + + private publish(id: string) { + const record = this.configs.get(id) + if (!record) return + this.options.eventBus.publish({ type: "sidecar.updated", sidecar: this.toSideCar(record) }) + } + + private toSideCar(record: SideCarConfigRecord): SideCar { + const runtime = this.runtime.get(record.id) + return { + id: record.id, + kind: record.kind, + name: record.name, + port: record.port, + insecure: record.insecure, + prefixMode: record.prefixMode, + status: runtime?.status ?? "stopped", + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } + } + + private requireConfig(id: string): SideCarConfigRecord { + const record = this.configs.get(id) + if (!record) { + throw new Error("SideCar not found") + } + return record + } + + private persistConfigs() { + const sidecars = Array.from(this.configs.values()).map((record) => ({ ...record })) + this.options.settings.mergePatchOwner("config", "server", { sidecars }) + } + + private loadConfiguredSideCars(): SideCarConfigRecord[] { + const serverConfig = this.options.settings.getOwner("config", "server") as { sidecars?: unknown } + const list = Array.isArray(serverConfig?.sidecars) ? serverConfig.sidecars : [] + const records: SideCarConfigRecord[] = [] + for (const item of list) { + if (!item || typeof item !== "object") continue + const record = item as Record + const kind = record.kind === "port" ? "port" : null + const id = typeof record.id === "string" && record.id.trim() ? record.id.trim() : null + const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : null + const port = typeof record.port === "number" && Number.isInteger(record.port) ? record.port : null + if (!kind || !id || !name || !port) continue + + const insecure = record.insecure === true + const prefixMode = record.prefixMode === "preserve" ? "preserve" : "strip" + const createdAt = typeof record.createdAt === "string" && record.createdAt ? record.createdAt : new Date().toISOString() + const updatedAt = typeof record.updatedAt === "string" && record.updatedAt ? record.updatedAt : createdAt + records.push({ id, kind, name, port, insecure, prefixMode, createdAt, updatedAt }) + } + return records + } + + private isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const socket = connect({ port, host: "127.0.0.1" }, () => { + socket.end() + resolve(true) + }) + socket.once("error", () => { + socket.destroy() + resolve(false) + }) + }) + } + + private buildSideCarId(name: string): string { + const normalized = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-{2,}/g, "-") + .replace(/^-|-$/g, "") + + if (!normalized) { + throw new Error("SideCar name must include letters or numbers") + } + + return normalized + } +} diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 0e4e13b9..462c0c16 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,7 +10,10 @@ import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell2" import { SettingsScreen } from "./components/settings-screen" +import { SideCarPickerDialog } from "./components/sidecar-picker-dialog" +import { SideCarView } from "./components/sidecar-view" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" +import { showAlertDialog } from "./stores/alerts" import { initGithubStars } from "./stores/github-stars" import { useCommands } from "./lib/hooks/use-commands" @@ -23,7 +26,6 @@ import { runtimeEnv } from "./lib/runtime-env" import { useI18n } from "./lib/i18n" import { setWakeLockDesired } from "./lib/native/wake-lock" import { - hasInstances, isSelectingFolder, setIsSelectingFolder, showFolderSelection, @@ -33,10 +35,7 @@ import { useConfig } from "./stores/preferences" import { createInstance, instances, - activeInstanceId, - setActiveInstanceId, stopInstance, - getActiveInstance, disconnectedInstance, acknowledgeDisconnectedInstance, } from "./stores/instances" @@ -53,6 +52,22 @@ import { import { getInstanceSessionIndicatorStatus } from "./stores/session-status" import { openSettings } from "./stores/settings-screen" +import { + closeSidecarTab, + ensureSidecarsLoaded, + openSidecarTab, +} from "./stores/sidecars" +import { + activeAppTab, + activeAppTabId, + appTabs, + ensureActiveAppTab, + getAdjacentAppTabId, + getAppTabById, + selectAppTab, + selectInstanceTab, + selectSidecarTab, +} from "./stores/app-tabs" const log = getLogger("actions") @@ -77,6 +92,7 @@ const App: Component = () => { } = useConfig() const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) + const [sidecarPickerOpen, setSidecarPickerOpen] = createSignal(false) const phoneQuery = useMediaQuery("(max-width: 767px)") const isPhoneLayout = createMemo(() => phoneQuery()) @@ -206,8 +222,7 @@ const App: Component = () => { }) createEffect(() => { - instances() - hasInstances() + appTabs() requestAnimationFrame(() => updateInstanceTabBarHeight()) }) @@ -219,7 +234,15 @@ const App: Component = () => { onCleanup(() => window.removeEventListener("resize", handleResize)) }) - const activeInstance = createMemo(() => getActiveInstance()) + createEffect(() => { + appTabs() + ensureActiveAppTab() + }) + + const activeInstance = createMemo(() => { + const tab = activeAppTab() + return tab?.kind === "instance" ? tab.instance : null + }) const activeSessionIdForInstance = createMemo(() => { const instance = activeInstance() if (!instance) return null @@ -244,6 +267,7 @@ const App: Component = () => { recordWorkspaceLaunch(folderPath, selectedBinary) clearLaunchError() const instanceId = await createInstance(folderPath, selectedBinary) + selectInstanceTab(instanceId) setShowFolderSelection(false) log.info("Created instance", { @@ -270,8 +294,27 @@ const App: Component = () => { } function handleNewInstanceRequest() { - if (hasInstances()) { - setShowFolderSelection(true) + setShowFolderSelection(true) + } + + function handleOpenSidecarPicker() { + setSidecarPickerOpen(true) + void ensureSidecarsLoaded() + } + + async function handleOpenSidecar(sidecarId: string) { + try { + const tab = await openSidecarTab(sidecarId) + selectSidecarTab(tab.token) + setShowFolderSelection(false) + setSidecarPickerOpen(false) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + showAlertDialog(message, { + variant: "error", + title: t("sidecars.open.errorTitle"), + }) + log.error("Failed to open SideCar", error) } } @@ -332,6 +375,23 @@ const App: Component = () => { } } + async function handleCloseAppTab(tabId: string) { + const tab = getAppTabById(tabId) + if (!tab) return + + const fallbackTabId = activeAppTabId() === tabId ? getAdjacentAppTabId(tabId) : activeAppTabId() + + if (tab.kind === "instance") { + await handleCloseInstance(tab.instance.id) + } else { + closeSidecarTab(tab.sidecarTab.token) + } + + if (!getAppTabById(tabId)) { + ensureActiveAppTab(fallbackTabId) + } + } + const handleSidebarAgentChange = async (instanceId: string, sessionId: string, agent: string) => { if (!instanceId || !sessionId || sessionId === "info") return await updateSessionAgent(instanceId, sessionId, agent) @@ -361,6 +421,7 @@ const App: Component = () => { setThinkingBlocksExpansion, setToolInputsVisibility, handleNewInstanceRequest, + handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), handleCloseInstance, handleNewSession, handleCloseSession, @@ -371,6 +432,7 @@ const App: Component = () => { useAppLifecycle({ setEscapeInDebounce, handleNewInstanceRequest, + handleCloseActiveTab: () => handleCloseAppTab(activeAppTabId() ?? ""), handleCloseInstance, handleNewSession, handleCloseSession, @@ -470,52 +532,60 @@ const App: Component = () => { void handleCloseAppTab(tabId)} onNew={handleNewInstanceRequest} /> - - - {(instance) => { - const isActiveInstance = () => activeInstanceId() === instance.id - const isVisible = () => isActiveInstance() && !showFolderSelection() - return ( -
- - handleCloseSession(instance.id, sessionId)} - onNewSession={() => handleNewSession(instance.id)} - handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} - handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} - onExecuteCommand={executeCommand} - tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} - mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} - onEnterMobileFullscreen={() => void enterMobileFullscreen()} - onExitMobileFullscreen={() => void exitMobileFullscreen()} - /> - - -
- ) + + {(tab) => { + const isVisible = () => activeAppTabId() === tab.id && !showFolderSelection() + return tab.kind === "instance" ? ( +
+ + handleCloseSession(tab.instance.id, sessionId)} + onNewSession={() => handleNewSession(tab.instance.id)} + handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(tab.instance.id, sessionId, agent)} + handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(tab.instance.id, sessionId, model)} + onExecuteCommand={executeCommand} + tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()} + mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()} + onEnterMobileFullscreen={() => void enterMobileFullscreen()} + onExitMobileFullscreen={() => void exitMobileFullscreen()} + /> + +
+ ) : ( +
+ +
+ ) }}
@@ -525,6 +595,7 @@ const App: Component = () => {
@@ -534,6 +605,7 @@ const App: Component = () => { { setShowFolderSelection(false) clearLaunchError() @@ -544,6 +616,7 @@ const App: Component = () => { + setSidecarPickerOpen(false)} onOpenSidecar={handleOpenSidecar} /> diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 030a2791..90459ada 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -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 = (props) => {
- + - + + +
{/* OpenCode settings section */} diff --git a/packages/ui/src/components/instance-tabs.tsx b/packages/ui/src/components/instance-tabs.tsx index 195d3f4b..86e2c498 100644 --- a/packages/ui/src/components/instance-tabs.tsx +++ b/packages/ui/src/components/instance-tabs.tsx @@ -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 - 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 = (props) => {
- - {([id, instance]) => ( - props.onSelect(id)} - onClose={() => props.onClose(id)} - /> - )} + + {(tab) => + tab.kind === "instance" ? ( + props.onSelect(tab.id)} + onClose={() => props.onClose(tab.id)} + /> + ) : ( +
+ + +
+ )}
- 1}> + 1}>
{ 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 case "speech": return + case "sidecars": + return case "opencode": return case "appearance": diff --git a/packages/ui/src/components/settings/sidecars-settings-section.tsx b/packages/ui/src/components/settings/sidecars-settings-section.tsx new file mode 100644 index 00000000..c70d72b6 --- /dev/null +++ b/packages/ui/src/components/settings/sidecars-settings-section.tsx @@ -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(null) + const [creating, setCreating] = createSignal(false) + const [formError, setFormError] = createSignal(null) + const [actionError, setActionError] = createSignal(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 ( +
+
+
+
+ +
+

{t("settings.section.sidecars.title")}

+

{t("settings.section.sidecars.subtitle")}

+
+
+ {t("settings.scope.server")} +
+ +
+
+
+
{t("sidecars.form.name")}
+
{t("sidecars.basePath")}: /sidecars/{derivedId()}
+
+ { + setFormError(null) + setName(event.currentTarget.value) + }} + /> +
+ +
+
+
{t("sidecars.form.port")}
+
127.0.0.1
+
+ { + setFormError(null) + setPort(event.currentTarget.value) + }} + inputMode="numeric" + /> +
+ +
+
+
{t("sidecars.form.protocol")}
+
{t("sidecars.form.protocol.help")}
+
+ +
+ +
+
+
{t("sidecars.form.prefixMode")}
+
{t("sidecars.form.prefixMode.help")}
+
+ +
+ + +
{formError()}
+
+ +
+ +
+
+
+ +
+
+
+

{t("sidecars.settings.listTitle")}

+

{t("sidecars.settings.listSubtitle")}

+
+
+ +
+ +
{actionError()}
+
+ + {t("sidecars.picker.loading")}
}> + 0} fallback={
{t("sidecars.settings.empty")}
}> + + {(sidecar) => ( +
+
+
{sidecar.name}
+
+ {t("sidecars.kind.port")} · {sidecar.insecure ? "http" : "https"}://127.0.0.1:{sidecar.port} +
+
+ {t("sidecars.basePath")}: /sidecars/{sidecar.id} · {t(`sidecars.form.prefixMode.${sidecar.prefixMode}`)} +
+
+ +
+ {t(`sidecars.status.${sidecar.status}`)} + +
+
+ )} +
+
+ +
+
+
+ ) +} diff --git a/packages/ui/src/components/sidecar-picker-dialog.tsx b/packages/ui/src/components/sidecar-picker-dialog.tsx new file mode 100644 index 00000000..be2ce57f --- /dev/null +++ b/packages/ui/src/components/sidecar-picker-dialog.tsx @@ -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 +} + +export const SideCarPickerDialog: Component = (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 ( + !open && props.onClose()}> + + +
+ +
+ {t("sidecars.picker.title")} + + {t("sidecars.picker.subtitle")} + +
+ +
+ {t("sidecars.picker.loading")}
}> + 0} fallback={
{t("sidecars.picker.empty")}
}> + + {(sidecar) => ( + + )} + +
+ +
+ +
+ +
+ +
+ + + ) +} diff --git a/packages/ui/src/components/sidecar-view.tsx b/packages/ui/src/components/sidecar-view.tsx new file mode 100644 index 00000000..f326e5f4 --- /dev/null +++ b/packages/ui/src/components/sidecar-view.tsx @@ -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 = (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 ( +
+
+ + +
+ {lockedBaseLabel()} +
+
handleGo(event)}> + setPathInput(event.currentTarget.value)} + spellcheck={false} + autocomplete="off" + autocorrect="off" + autocapitalize="off" + aria-label={t("sidecars.path")} + /> + +
+
+