From 96f19b6fee325121bc9f9414d763e6ff5c1ac6ad Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 1 Apr 2026 14:36:50 -0700 Subject: [PATCH 01/10] height --- .../usePaneRegistry/components/TerminalPane/TerminalPane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 33e06b1d555..0adcbd438c1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -161,7 +161,7 @@ export function TerminalPane({ workspaceId }: WorkspaceTerminalProps) {
); From 9830d1542543e5226855b5a4f11c81edd6b65716 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 1 Apr 2026 21:16:03 -0700 Subject: [PATCH 02/10] lifecycle --- .../lib/terminal/terminal-runtime-registry.ts | 284 ++++++++++++++++++ .../components/TerminalPane/TerminalPane.tsx | 177 +++-------- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 7 +- .../GlobalTerminalLifecycle.tsx | 6 + .../GlobalTerminalLifecycle/index.ts | 1 + .../hooks/useGlobalTerminalLifecycle/index.ts | 1 + .../useGlobalTerminalLifecycle.ts | 97 ++++++ .../renderer/routes/_authenticated/layout.tsx | 2 + .../host-service/src/terminal/terminal.ts | 254 +++++++++++----- 9 files changed, 603 insertions(+), 226 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts new file mode 100644 index 00000000000..278ded0ccb9 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -0,0 +1,284 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal as XTerm } from "@xterm/xterm"; + +type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; + +interface TerminalRuntime { + paneId: string; + terminal: XTerm; + fitAddon: FitAddon; + /** Persistent wrapper div that xterm renders into. Survives detach/reattach. */ + wrapper: HTMLDivElement; + socket: WebSocket | null; + connectionState: ConnectionState; + /** The visible container element this runtime is currently attached to. */ + container: HTMLDivElement | null; + resizeObserver: ResizeObserver | null; + onDataDisposable: { dispose(): void } | null; + /** Listeners notified when connectionState changes. */ + stateListeners: Set<() => void>; +} + +type TerminalServerMessage = + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; + +function createTerminal(): { terminal: XTerm; fitAddon: FitAddon } { + const fitAddon = new FitAddon(); + const terminal = new XTerm({ + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + theme: { + background: "#14100f", + foreground: "#f5efe9", + }, + }); + terminal.loadAddon(fitAddon); + return { terminal, fitAddon }; +} + +function setConnectionState(runtime: TerminalRuntime, state: ConnectionState) { + runtime.connectionState = state; + for (const listener of runtime.stateListeners) { + listener(); + } +} + +function connectSocket(runtime: TerminalRuntime, wsUrl: string) { + // Close any existing socket + if (runtime.socket) { + runtime.socket.close(); + runtime.socket = null; + } + + setConnectionState(runtime, "connecting"); + const socket = new WebSocket(wsUrl); + runtime.socket = socket; + + const sendResize = () => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send( + JSON.stringify({ + type: "resize", + cols: runtime.terminal.cols, + rows: runtime.terminal.rows, + }), + ); + }; + + socket.addEventListener("open", () => { + if (runtime.socket !== socket) return; + setConnectionState(runtime, "open"); + sendResize(); + }); + + socket.addEventListener("message", (event) => { + if (runtime.socket !== socket) return; + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + runtime.terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data" || message.type === "replay") { + runtime.terminal.write(message.data); + return; + } + + if (message.type === "error") { + runtime.terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + if (message.type === "exit") { + runtime.terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + } + }); + + socket.addEventListener("close", () => { + if (runtime.socket !== socket) return; + setConnectionState(runtime, "closed"); + runtime.socket = null; + }); + + socket.addEventListener("error", () => { + if (runtime.socket !== socket) return; + runtime.terminal.writeln("\r\n[terminal] websocket error"); + }); + + // Wire terminal input → socket + runtime.onDataDisposable?.dispose(); + runtime.onDataDisposable = runtime.terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }); + + // Set up resize observer if attached + if (runtime.container) { + setupResizeObserver(runtime, sendResize); + } +} + +function setupResizeObserver( + runtime: TerminalRuntime, + sendResize: () => void, +) { + runtime.resizeObserver?.disconnect(); + if (!runtime.container) return; + + const observer = new ResizeObserver(() => { + runtime.fitAddon.fit(); + sendResize(); + }); + observer.observe(runtime.container); + runtime.resizeObserver = observer; +} + +function teardownResizeObserver(runtime: TerminalRuntime) { + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; +} + +class TerminalRuntimeRegistryImpl { + private runtimes = new Map(); + + /** + * Get or create a terminal runtime for the given paneId. + * The xterm instance is created but not connected until attach(). + */ + getOrCreate(paneId: string): TerminalRuntime { + let runtime = this.runtimes.get(paneId); + if (runtime) return runtime; + + const { terminal, fitAddon } = createTerminal(); + + // Create a persistent wrapper div that xterm renders into. + // This wrapper survives detach/reattach cycles. + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + terminal.open(wrapper); + + runtime = { + paneId, + terminal, + fitAddon, + wrapper, + socket: null, + connectionState: "disconnected", + container: null, + resizeObserver: null, + onDataDisposable: null, + stateListeners: new Set(), + }; + + this.runtimes.set(paneId, runtime); + return runtime; + } + + /** + * Attach a terminal runtime to a visible DOM container and connect its websocket. + */ + attach(paneId: string, container: HTMLDivElement, wsUrl: string) { + const runtime = this.getOrCreate(paneId); + + // Move the persistent wrapper into the visible container + runtime.container = container; + container.appendChild(runtime.wrapper); + runtime.fitAddon.fit(); + runtime.terminal.focus(); + + // Connect (or reconnect) the websocket + connectSocket(runtime, wsUrl); + } + + /** + * Detach a terminal runtime from the DOM and disconnect the websocket. + * The xterm instance and its buffer are preserved in memory. + */ + detach(paneId: string) { + const runtime = this.runtimes.get(paneId); + if (!runtime) return; + + // Disconnect socket — server will keep PTY alive + if (runtime.socket) { + runtime.socket.close(); + runtime.socket = null; + } + setConnectionState(runtime, "disconnected"); + + // Clean up DOM observers + teardownResizeObserver(runtime); + runtime.onDataDisposable?.dispose(); + runtime.onDataDisposable = null; + + // Remove wrapper from container (keeps wrapper + xterm in memory) + runtime.wrapper.remove(); + runtime.container = null; + } + + /** + * Fully dispose a terminal runtime: send dispose to server, close socket, + * destroy xterm, and remove from registry. + */ + dispose(paneId: string) { + const runtime = this.runtimes.get(paneId); + if (!runtime) return; + + // Tell server to kill the PTY + if (runtime.socket && runtime.socket.readyState === WebSocket.OPEN) { + runtime.socket.send(JSON.stringify({ type: "dispose" })); + } + + // Clean up everything + if (runtime.socket) { + runtime.socket.close(); + runtime.socket = null; + } + teardownResizeObserver(runtime); + runtime.onDataDisposable?.dispose(); + runtime.onDataDisposable = null; + runtime.wrapper.remove(); + runtime.terminal.dispose(); + runtime.stateListeners.clear(); + + this.runtimes.delete(paneId); + } + + /** Get all paneIds currently in the registry. */ + getAllPaneIds(): Set { + return new Set(this.runtimes.keys()); + } + + /** Check whether a runtime exists for this paneId. */ + has(paneId: string): boolean { + return this.runtimes.has(paneId); + } + + /** Get the connection state for a runtime. */ + getConnectionState(paneId: string): ConnectionState { + return this.runtimes.get(paneId)?.connectionState ?? "disconnected"; + } + + /** Subscribe to connection state changes for a runtime. Returns unsubscribe fn. */ + onStateChange(paneId: string, listener: () => void): () => void { + const runtime = this.runtimes.get(paneId); + if (!runtime) return () => {}; + runtime.stateListeners.add(listener); + return () => { + runtime.stateListeners.delete(listener); + }; + } +} + +export const terminalRuntimeRegistry = new TerminalRuntimeRegistryImpl(); + +export type { ConnectionState }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 0adcbd438c1..0ca024127cf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -1,168 +1,59 @@ -import { Button } from "@superset/ui/button"; -import { FitAddon } from "@xterm/addon-fit"; -import { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; -interface WorkspaceTerminalProps { +interface TerminalPaneProps { + paneId: string; workspaceId: string; } -type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; +function subscribeToState(paneId: string) { + return (callback: () => void) => + terminalRuntimeRegistry.onStateChange(paneId, callback); +} + +function getConnectionState(paneId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(paneId); +} -export function TerminalPane({ workspaceId }: WorkspaceTerminalProps) { +export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); - const [connectionState, setConnectionState] = useState< - "connecting" | "open" | "closed" - >("connecting"); - const [reconnectKey, setReconnectKey] = useState(0); - const websocketUrl = useWorkspaceWsUrl(`/terminal/${workspaceId}`, { - reconnect: String(reconnectKey), + const websocketUrl = useWorkspaceWsUrl(`/terminal/${paneId}`, { + workspaceId, }); + const connectionState = useSyncExternalStore( + subscribeToState(paneId), + () => getConnectionState(paneId), + ); + useEffect(() => { const container = containerRef.current; - if (!container) { - return; - } - - const fitAddon = new FitAddon(); - const terminal = new XTerm({ - cursorBlink: true, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 12, - theme: { - background: "#14100f", - foreground: "#f5efe9", - }, - }); - terminal.loadAddon(fitAddon); - terminal.open(container); - fitAddon.fit(); - terminal.focus(); - - setConnectionState("connecting"); - const socket = new WebSocket(websocketUrl); - - const sendResize = () => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "resize", - cols: terminal.cols, - rows: terminal.rows, - }), - ); - }; - - const resizeObserver = new ResizeObserver(() => { - fitAddon.fit(); - sendResize(); - }); - resizeObserver.observe(container); + if (!container) return; - const onTerminalDataDispose = terminal.onData((data) => { - if (socket.readyState !== WebSocket.OPEN) { - return; - } - - socket.send( - JSON.stringify({ - type: "input", - data, - }), - ); - }); - - socket.addEventListener("open", () => { - setConnectionState("open"); - sendResize(); - }); - - socket.addEventListener("message", (event) => { - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - terminal.writeln("\r\n[terminal] invalid server payload"); - return; - } - - if (message.type === "data") { - terminal.write(message.data); - return; - } - - if (message.type === "error") { - terminal.writeln(`\r\n[terminal] ${message.message}`); - return; - } - - terminal.writeln( - `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, - ); - }); - - socket.addEventListener("close", () => { - setConnectionState("closed"); - }); - - socket.addEventListener("error", () => { - terminal.writeln("\r\n[terminal] websocket error"); - }); + terminalRuntimeRegistry.attach(paneId, container, websocketUrl); return () => { - resizeObserver.disconnect(); - onTerminalDataDispose.dispose(); - socket.close(); - terminal.dispose(); + terminalRuntimeRegistry.detach(paneId); }; - }, [websocketUrl]); + }, [paneId, websocketUrl]); return ( -
-
-
-

terminal

-

- {connectionState === "open" - ? "Connected" - : connectionState === "connecting" - ? "Connecting..." - : "Disconnected"} -

-
- -
+
+ {connectionState === "closed" && ( +
+ Disconnected +
+ )}
); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index 88d8e394878..f7bfd62bcc9 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -40,8 +40,11 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: () => ( - + renderPane: (ctx: RendererContext) => ( + ), }, browser: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx new file mode 100644 index 00000000000..5e30ebdf848 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx @@ -0,0 +1,6 @@ +import { useGlobalTerminalLifecycle } from "../../hooks/useGlobalTerminalLifecycle"; + +export function GlobalTerminalLifecycle() { + useGlobalTerminalLifecycle(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..d2ed752bdc2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { GlobalTerminalLifecycle } from "./GlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts new file mode 100644 index 00000000000..42a2df8af11 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts @@ -0,0 +1 @@ +export { useGlobalTerminalLifecycle } from "./useGlobalTerminalLifecycle"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts new file mode 100644 index 00000000000..7ed925df854 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -0,0 +1,97 @@ +import type { WorkspaceState } from "@superset/panes"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useEffect, useRef } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { useCollections } from "../../providers/CollectionsProvider"; + +/** Delay before confirming a pane removal is permanent (handles cross-workspace moves). */ +const DISPOSE_DELAY_MS = 500; + +function extractTerminalPaneIds( + rows: { paneLayout: unknown }[], +): Set { + const ids = new Set(); + for (const row of rows) { + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + for (const tab of layout.tabs) { + for (const [paneId, pane] of Object.entries(tab.panes)) { + if (pane.kind === "terminal") { + ids.add(paneId); + } + } + } + } + return ids; +} + +/** + * Global hook that watches all persisted workspace layouts and disposes + * terminal runtimes only when their paneId disappears from every workspace. + * + * Must be mounted once, inside CollectionsProvider, above workspace routes. + */ +export function useGlobalTerminalLifecycle() { + const collections = useCollections(); + const prevPaneIdsRef = useRef>(new Set()); + const pendingDisposals = useRef>>( + new Map(), + ); + + const { data: allWorkspaceRows = [] } = useLiveQuery( + (query) => + query.from({ + v2WorkspaceLocalState: collections.v2WorkspaceLocalState, + }), + [collections], + ); + + useEffect(() => { + const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); + const prevPaneIds = prevPaneIdsRef.current; + + // Cancel pending disposals for paneIds that reappeared (cross-workspace move completed) + for (const paneId of currentPaneIds) { + const timer = pendingDisposals.current.get(paneId); + if (timer) { + clearTimeout(timer); + pendingDisposals.current.delete(paneId); + } + } + + // Find paneIds that disappeared + for (const paneId of prevPaneIds) { + if (currentPaneIds.has(paneId)) continue; + if (pendingDisposals.current.has(paneId)) continue; + + // Schedule disposal with delay to handle atomic cross-workspace moves + const timer = setTimeout(() => { + pendingDisposals.current.delete(paneId); + + // Re-read current global state to confirm the pane is still gone + const freshRows = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ); + const freshIds = extractTerminalPaneIds(freshRows); + + if (!freshIds.has(paneId)) { + terminalRuntimeRegistry.dispose(paneId); + } + }, DISPOSE_DELAY_MS); + + pendingDisposals.current.set(paneId, timer); + } + + prevPaneIdsRef.current = currentPaneIds; + }, [allWorkspaceRows, collections]); + + // Cleanup pending timers on unmount + useEffect(() => { + return () => { + for (const timer of pendingDisposals.current.values()) { + clearTimeout(timer); + } + pendingDisposals.current.clear(); + }; + }, []); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index 8083d6b0dec..fdf03790792 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -32,6 +32,7 @@ import { setPaneWorkspaceRunState } from "renderer/stores/tabs/workspace-run"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID, NOTIFICATION_EVENTS } from "shared/constants"; import { AgentHooks } from "./components/AgentHooks"; +import { GlobalTerminalLifecycle } from "./components/GlobalTerminalLifecycle"; import { TeardownLogsDialog } from "./components/TeardownLogsDialog"; import { CollectionsProvider } from "./providers/CollectionsProvider"; import { HostServiceProvider } from "./providers/HostServiceProvider"; @@ -174,6 +175,7 @@ function AuthenticatedLayout() { return ( + diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 3e50c23c704..e3f2b79b53a 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -14,41 +14,40 @@ interface RegisterWorkspaceTerminalRouteOptions { } type TerminalClientMessage = - | { - type: "input"; - data: string; - } - | { - type: "resize"; - cols: number; - rows: number; - }; + | { type: "input"; data: string } + | { type: "resize"; cols: number; rows: number } + | { type: "dispose" }; type TerminalServerMessage = - | { - type: "data"; - data: string; - } - | { - type: "error"; - message: string; - } - | { - type: "exit"; - exitCode: number; - signal: number; - }; + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; + +/** Maximum bytes to buffer while a terminal is detached. */ +const MAX_BUFFER_BYTES = 64 * 1024; + +interface TerminalSession { + paneId: string; + pty: IPty; + /** Currently attached websocket, or null if detached. */ + socket: { send: (data: string) => void; readyState: number } | null; + /** Output buffered while detached. */ + buffer: string[]; + bufferBytes: number; + exited: boolean; + exitCode: number; + exitSignal: number; +} + +/** Server-side session registry. PTY lifetime is independent of socket lifetime. */ +const sessions = new Map(); function sendMessage( - socket: { - send: (data: string) => void; - readyState: number; - }, + socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, ) { - if (socket.readyState !== 1) { - return; - } + if (socket.readyState !== 1) return; socket.send(JSON.stringify(message)); } @@ -56,44 +55,98 @@ function resolveShell(): string { if (process.platform === "win32") { return process.env.COMSPEC || "cmd.exe"; } - return process.env.SHELL || "/bin/zsh"; } +function bufferOutput(session: TerminalSession, data: string) { + session.buffer.push(data); + session.bufferBytes += data.length; + + // Trim from front if over limit + while (session.bufferBytes > MAX_BUFFER_BYTES && session.buffer.length > 1) { + const removed = session.buffer.shift(); + if (removed) session.bufferBytes -= removed.length; + } +} + +function replayBuffer( + session: TerminalSession, + socket: { send: (data: string) => void; readyState: number }, +) { + if (session.buffer.length === 0) return; + const combined = session.buffer.join(""); + session.buffer.length = 0; + session.bufferBytes = 0; + sendMessage(socket, { type: "replay", data: combined }); +} + +function disposeSession(paneId: string) { + const session = sessions.get(paneId); + if (!session) return; + + if (!session.exited) { + try { + session.pty.kill(); + } catch { + // PTY may already be dead + } + } + sessions.delete(paneId); +} + export function registerWorkspaceTerminalRoute({ app, db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { app.get( - "/terminal/:workspaceId", + "/terminal/:paneId", upgradeWebSocket((c) => { - const workspaceId = c.req.param("workspaceId"); - const workspace = workspaceId - ? db.query.workspaces - .findFirst({ where: eq(workspaces.id, workspaceId) }) - .sync() - : null; - - let terminal: IPty | null = null; - let disposed = false; - - const disposeTerminal = () => { - if (disposed) { - return; - } - disposed = true; - terminal?.kill(); - terminal = null; - }; + const paneId = c.req.param("paneId"); + const workspaceId = c.req.query("workspaceId") ?? null; return { onOpen: (_event, ws) => { - if ( - !workspaceId || - !workspace || - !existsSync(workspace.worktreePath) - ) { + if (!paneId) { + sendMessage(ws, { + type: "error", + message: "Missing paneId", + }); + ws.close(1011, "Missing paneId"); + return; + } + + // Check for existing session (reconnection) + const existing = sessions.get(paneId); + if (existing) { + existing.socket = ws; + replayBuffer(existing, ws); + + if (existing.exited) { + sendMessage(ws, { + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, + }); + } + return; + } + + // New session — need workspaceId to look up cwd + if (!workspaceId) { + sendMessage(ws, { + type: "error", + message: "Missing workspaceId for new terminal session", + }); + ws.close(1011, "Missing workspaceId"); + return; + } + + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace || !existsSync(workspace.worktreePath)) { sendMessage(ws, { type: "error", message: "Workspace worktree not found", @@ -102,8 +155,9 @@ export function registerWorkspaceTerminalRoute({ return; } + let pty: IPty; try { - terminal = spawn(resolveShell(), [], { + pty = spawn(resolveShell(), [], { name: "xterm-256color", cwd: workspace.worktreePath, cols: 120, @@ -128,55 +182,93 @@ export function registerWorkspaceTerminalRoute({ return; } - terminal.onData((data) => { - sendMessage(ws, { - type: "data", - data, - }); + const session: TerminalSession = { + paneId, + pty, + socket: ws, + buffer: [], + bufferBytes: 0, + exited: false, + exitCode: 0, + exitSignal: 0, + }; + sessions.set(paneId, session); + + pty.onData((data) => { + if (session.socket && session.socket.readyState === 1) { + sendMessage(session.socket, { type: "data", data }); + } else { + // Buffer output while detached + bufferOutput(session, data); + } }); - terminal.onExit(({ exitCode, signal }) => { - sendMessage(ws, { - type: "exit", - exitCode: exitCode ?? 0, - signal: signal ?? 0, - }); - ws.close(1000, "Terminal exited"); - disposeTerminal(); + pty.onExit(({ exitCode, signal }) => { + session.exited = true; + session.exitCode = exitCode ?? 0; + session.exitSignal = signal ?? 0; + + if (session.socket && session.socket.readyState === 1) { + sendMessage(session.socket, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); + } }); }, - onMessage: (event, ws) => { - if (!terminal) { - return; - } + + onMessage: (event, _ws) => { + const session = sessions.get(paneId ?? ""); + if (!session) return; let message: TerminalClientMessage; try { - message = JSON.parse(String(event.data)) as TerminalClientMessage; + message = JSON.parse( + String(event.data), + ) as TerminalClientMessage; } catch { - sendMessage(ws, { - type: "error", - message: "Invalid terminal message payload", - }); + if (session.socket) { + sendMessage(session.socket, { + type: "error", + message: "Invalid terminal message payload", + }); + } + return; + } + + if (message.type === "dispose") { + disposeSession(paneId ?? ""); return; } + if (session.exited) return; + if (message.type === "input") { - terminal.write(message.data); + session.pty.write(message.data); return; } if (message.type === "resize") { const cols = Math.max(20, Math.floor(message.cols)); const rows = Math.max(5, Math.floor(message.rows)); - terminal.resize(cols, rows); + session.pty.resize(cols, rows); } }, + onClose: () => { - disposeTerminal(); + // Detach only — keep PTY alive + const session = sessions.get(paneId ?? ""); + if (session) { + session.socket = null; + } }, + onError: () => { - disposeTerminal(); + const session = sessions.get(paneId ?? ""); + if (session) { + session.socket = null; + } }, }; }), From 99a7061b8b7073f22fcbe84b709852a8ec6793c4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 1 Apr 2026 21:52:49 -0700 Subject: [PATCH 03/10] Deslop --- .../lib/terminal/terminal-runtime-registry.ts | 82 +++++++++---------- .../useGlobalTerminalLifecycle.ts | 13 +-- .../host-service/src/terminal/terminal.ts | 15 +--- 3 files changed, 45 insertions(+), 65 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 278ded0ccb9..7a0a13a4ec9 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -1,4 +1,5 @@ import { FitAddon } from "@xterm/addon-fit"; +import { SerializeAddon } from "@xterm/addon-serialize"; import { Terminal as XTerm } from "@xterm/xterm"; type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; @@ -7,15 +8,14 @@ interface TerminalRuntime { paneId: string; terminal: XTerm; fitAddon: FitAddon; - /** Persistent wrapper div that xterm renders into. Survives detach/reattach. */ + serializeAddon: SerializeAddon; + /** xterm renders into this div. It is reparented between containers across attach/detach cycles. */ wrapper: HTMLDivElement; socket: WebSocket | null; connectionState: ConnectionState; - /** The visible container element this runtime is currently attached to. */ container: HTMLDivElement | null; resizeObserver: ResizeObserver | null; onDataDisposable: { dispose(): void } | null; - /** Listeners notified when connectionState changes. */ stateListeners: Set<() => void>; } @@ -25,8 +25,16 @@ type TerminalServerMessage = | { type: "exit"; exitCode: number; signal: number } | { type: "replay"; data: string }; -function createTerminal(): { terminal: XTerm; fitAddon: FitAddon } { +const SERIALIZE_SCROLLBACK = 1000; +const STORAGE_KEY_PREFIX = "terminal-buffer:"; + +function createTerminal(): { + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; +} { const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); const terminal = new XTerm({ cursorBlink: true, fontFamily: @@ -38,7 +46,28 @@ function createTerminal(): { terminal: XTerm; fitAddon: FitAddon } { }, }); terminal.loadAddon(fitAddon); - return { terminal, fitAddon }; + terminal.loadAddon(serializeAddon); + return { terminal, fitAddon, serializeAddon }; +} + +function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { + try { + const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + } catch {} +} + +function restoreBuffer(paneId: string, terminal: XTerm) { + try { + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + if (data) terminal.write(data); + } catch {} +} + +function clearPersistedBuffer(paneId: string) { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + } catch {} } function setConnectionState(runtime: TerminalRuntime, state: ConnectionState) { @@ -49,7 +78,6 @@ function setConnectionState(runtime: TerminalRuntime, state: ConnectionState) { } function connectSocket(runtime: TerminalRuntime, wsUrl: string) { - // Close any existing socket if (runtime.socket) { runtime.socket.close(); runtime.socket = null; @@ -114,14 +142,12 @@ function connectSocket(runtime: TerminalRuntime, wsUrl: string) { runtime.terminal.writeln("\r\n[terminal] websocket error"); }); - // Wire terminal input → socket runtime.onDataDisposable?.dispose(); runtime.onDataDisposable = runtime.terminal.onData((data) => { if (socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify({ type: "input", data })); }); - // Set up resize observer if attached if (runtime.container) { setupResizeObserver(runtime, sendResize); } @@ -150,27 +176,23 @@ function teardownResizeObserver(runtime: TerminalRuntime) { class TerminalRuntimeRegistryImpl { private runtimes = new Map(); - /** - * Get or create a terminal runtime for the given paneId. - * The xterm instance is created but not connected until attach(). - */ getOrCreate(paneId: string): TerminalRuntime { let runtime = this.runtimes.get(paneId); if (runtime) return runtime; - const { terminal, fitAddon } = createTerminal(); + const { terminal, fitAddon, serializeAddon } = createTerminal(); - // Create a persistent wrapper div that xterm renders into. - // This wrapper survives detach/reattach cycles. const wrapper = document.createElement("div"); wrapper.style.width = "100%"; wrapper.style.height = "100%"; terminal.open(wrapper); + restoreBuffer(paneId, terminal); runtime = { paneId, terminal, fitAddon, + serializeAddon, wrapper, socket: null, connectionState: "disconnected", @@ -184,61 +206,42 @@ class TerminalRuntimeRegistryImpl { return runtime; } - /** - * Attach a terminal runtime to a visible DOM container and connect its websocket. - */ attach(paneId: string, container: HTMLDivElement, wsUrl: string) { const runtime = this.getOrCreate(paneId); - // Move the persistent wrapper into the visible container runtime.container = container; container.appendChild(runtime.wrapper); runtime.fitAddon.fit(); runtime.terminal.focus(); - - // Connect (or reconnect) the websocket connectSocket(runtime, wsUrl); } - /** - * Detach a terminal runtime from the DOM and disconnect the websocket. - * The xterm instance and its buffer are preserved in memory. - */ detach(paneId: string) { const runtime = this.runtimes.get(paneId); if (!runtime) return; - // Disconnect socket — server will keep PTY alive + persistBuffer(paneId, runtime.serializeAddon); + if (runtime.socket) { runtime.socket.close(); runtime.socket = null; } setConnectionState(runtime, "disconnected"); - - // Clean up DOM observers teardownResizeObserver(runtime); runtime.onDataDisposable?.dispose(); runtime.onDataDisposable = null; - - // Remove wrapper from container (keeps wrapper + xterm in memory) runtime.wrapper.remove(); runtime.container = null; } - /** - * Fully dispose a terminal runtime: send dispose to server, close socket, - * destroy xterm, and remove from registry. - */ dispose(paneId: string) { const runtime = this.runtimes.get(paneId); if (!runtime) return; - // Tell server to kill the PTY - if (runtime.socket && runtime.socket.readyState === WebSocket.OPEN) { + if (runtime.socket?.readyState === WebSocket.OPEN) { runtime.socket.send(JSON.stringify({ type: "dispose" })); } - // Clean up everything if (runtime.socket) { runtime.socket.close(); runtime.socket = null; @@ -249,26 +252,23 @@ class TerminalRuntimeRegistryImpl { runtime.wrapper.remove(); runtime.terminal.dispose(); runtime.stateListeners.clear(); + clearPersistedBuffer(paneId); this.runtimes.delete(paneId); } - /** Get all paneIds currently in the registry. */ getAllPaneIds(): Set { return new Set(this.runtimes.keys()); } - /** Check whether a runtime exists for this paneId. */ has(paneId: string): boolean { return this.runtimes.has(paneId); } - /** Get the connection state for a runtime. */ getConnectionState(paneId: string): ConnectionState { return this.runtimes.get(paneId)?.connectionState ?? "disconnected"; } - /** Subscribe to connection state changes for a runtime. Returns unsubscribe fn. */ onStateChange(paneId: string, listener: () => void): () => void { const runtime = this.runtimes.get(paneId); if (!runtime) return () => {}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index 7ed925df854..183b415060f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -4,7 +4,7 @@ import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "../../providers/CollectionsProvider"; -/** Delay before confirming a pane removal is permanent (handles cross-workspace moves). */ +/** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ const DISPOSE_DELAY_MS = 500; function extractTerminalPaneIds( @@ -25,12 +25,6 @@ function extractTerminalPaneIds( return ids; } -/** - * Global hook that watches all persisted workspace layouts and disposes - * terminal runtimes only when their paneId disappears from every workspace. - * - * Must be mounted once, inside CollectionsProvider, above workspace routes. - */ export function useGlobalTerminalLifecycle() { const collections = useCollections(); const prevPaneIdsRef = useRef>(new Set()); @@ -50,7 +44,6 @@ export function useGlobalTerminalLifecycle() { const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); const prevPaneIds = prevPaneIdsRef.current; - // Cancel pending disposals for paneIds that reappeared (cross-workspace move completed) for (const paneId of currentPaneIds) { const timer = pendingDisposals.current.get(paneId); if (timer) { @@ -59,16 +52,13 @@ export function useGlobalTerminalLifecycle() { } } - // Find paneIds that disappeared for (const paneId of prevPaneIds) { if (currentPaneIds.has(paneId)) continue; if (pendingDisposals.current.has(paneId)) continue; - // Schedule disposal with delay to handle atomic cross-workspace moves const timer = setTimeout(() => { pendingDisposals.current.delete(paneId); - // Re-read current global state to confirm the pane is still gone const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ); @@ -85,7 +75,6 @@ export function useGlobalTerminalLifecycle() { prevPaneIdsRef.current = currentPaneIds; }, [allWorkspaceRows, collections]); - // Cleanup pending timers on unmount useEffect(() => { return () => { for (const timer of pendingDisposals.current.values()) { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index e3f2b79b53a..73af3171d29 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -24,15 +24,12 @@ type TerminalServerMessage = | { type: "exit"; exitCode: number; signal: number } | { type: "replay"; data: string }; -/** Maximum bytes to buffer while a terminal is detached. */ const MAX_BUFFER_BYTES = 64 * 1024; interface TerminalSession { paneId: string; pty: IPty; - /** Currently attached websocket, or null if detached. */ socket: { send: (data: string) => void; readyState: number } | null; - /** Output buffered while detached. */ buffer: string[]; bufferBytes: number; exited: boolean; @@ -40,7 +37,7 @@ interface TerminalSession { exitSignal: number; } -/** Server-side session registry. PTY lifetime is independent of socket lifetime. */ +/** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ const sessions = new Map(); function sendMessage( @@ -62,7 +59,6 @@ function bufferOutput(session: TerminalSession, data: string) { session.buffer.push(data); session.bufferBytes += data.length; - // Trim from front if over limit while (session.bufferBytes > MAX_BUFFER_BYTES && session.buffer.length > 1) { const removed = session.buffer.shift(); if (removed) session.bufferBytes -= removed.length; @@ -116,12 +112,10 @@ export function registerWorkspaceTerminalRoute({ return; } - // Check for existing session (reconnection) const existing = sessions.get(paneId); if (existing) { existing.socket = ws; replayBuffer(existing, ws); - if (existing.exited) { sendMessage(ws, { type: "exit", @@ -132,7 +126,6 @@ export function registerWorkspaceTerminalRoute({ return; } - // New session — need workspaceId to look up cwd if (!workspaceId) { sendMessage(ws, { type: "error", @@ -195,10 +188,9 @@ export function registerWorkspaceTerminalRoute({ sessions.set(paneId, session); pty.onData((data) => { - if (session.socket && session.socket.readyState === 1) { + if (session.socket?.readyState === 1) { sendMessage(session.socket, { type: "data", data }); } else { - // Buffer output while detached bufferOutput(session, data); } }); @@ -208,7 +200,7 @@ export function registerWorkspaceTerminalRoute({ session.exitCode = exitCode ?? 0; session.exitSignal = signal ?? 0; - if (session.socket && session.socket.readyState === 1) { + if (session.socket?.readyState === 1) { sendMessage(session.socket, { type: "exit", exitCode: session.exitCode, @@ -257,7 +249,6 @@ export function registerWorkspaceTerminalRoute({ }, onClose: () => { - // Detach only — keep PTY alive const session = sessions.get(paneId ?? ""); if (session) { session.socket = null; From 5de854141e95fc5609ba4393fc4c2476202f829f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 1 Apr 2026 22:22:04 -0700 Subject: [PATCH 04/10] Refactor --- .../lib/terminal/terminal-runtime-registry.ts | 112 +++++++++++++----- 1 file changed, 85 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 7a0a13a4ec9..aa4737010f6 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -9,7 +9,7 @@ interface TerminalRuntime { terminal: XTerm; fitAddon: FitAddon; serializeAddon: SerializeAddon; - /** xterm renders into this div. It is reparented between containers across attach/detach cycles. */ + /** Reparented between containers across attach/detach cycles — not recreated. */ wrapper: HTMLDivElement; socket: WebSocket | null; connectionState: ConnectionState; @@ -17,6 +17,9 @@ interface TerminalRuntime { resizeObserver: ResizeObserver | null; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; + /** Fallback grid size used when the host is not visible. */ + lastCols: number; + lastRows: number; } type TerminalServerMessage = @@ -27,8 +30,14 @@ type TerminalServerMessage = const SERIALIZE_SCROLLBACK = 1000; const STORAGE_KEY_PREFIX = "terminal-buffer:"; - -function createTerminal(): { +const DIMS_KEY_PREFIX = "terminal-dims:"; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 32; + +function createTerminal( + cols: number, + rows: number, +): { terminal: XTerm; fitAddon: FitAddon; serializeAddon: SerializeAddon; @@ -36,6 +45,8 @@ function createTerminal(): { const fitAddon = new FitAddon(); const serializeAddon = new SerializeAddon(); const terminal = new XTerm({ + cols, + rows, cursorBlink: true, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', @@ -70,6 +81,60 @@ function clearPersistedBuffer(paneId: string) { } catch {} } +function persistDimensions(paneId: string, cols: number, rows: number) { + try { + localStorage.setItem( + `${DIMS_KEY_PREFIX}${paneId}`, + JSON.stringify({ cols, rows }), + ); + } catch {} +} + +function loadSavedDimensions( + paneId: string, +): { cols: number; rows: number } | null { + try { + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearPersistedDimensions(paneId: string) { + try { + localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + } catch {} +} + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function measureAndResize(runtime: TerminalRuntime) { + if (!hostIsVisible(runtime.container)) return; + runtime.fitAddon.fit(); + runtime.lastCols = runtime.terminal.cols; + runtime.lastRows = runtime.terminal.rows; +} + +function sendResize(runtime: TerminalRuntime) { + if (!runtime.socket || runtime.socket.readyState !== WebSocket.OPEN) return; + runtime.socket.send( + JSON.stringify({ + type: "resize", + cols: runtime.terminal.cols, + rows: runtime.terminal.rows, + }), + ); +} + function setConnectionState(runtime: TerminalRuntime, state: ConnectionState) { runtime.connectionState = state; for (const listener of runtime.stateListeners) { @@ -87,21 +152,10 @@ function connectSocket(runtime: TerminalRuntime, wsUrl: string) { const socket = new WebSocket(wsUrl); runtime.socket = socket; - const sendResize = () => { - if (socket.readyState !== WebSocket.OPEN) return; - socket.send( - JSON.stringify({ - type: "resize", - cols: runtime.terminal.cols, - rows: runtime.terminal.rows, - }), - ); - }; - socket.addEventListener("open", () => { if (runtime.socket !== socket) return; setConnectionState(runtime, "open"); - sendResize(); + sendResize(runtime); }); socket.addEventListener("message", (event) => { @@ -147,22 +201,15 @@ function connectSocket(runtime: TerminalRuntime, wsUrl: string) { if (socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify({ type: "input", data })); }); - - if (runtime.container) { - setupResizeObserver(runtime, sendResize); - } } -function setupResizeObserver( - runtime: TerminalRuntime, - sendResize: () => void, -) { +function setupResizeObserver(runtime: TerminalRuntime) { runtime.resizeObserver?.disconnect(); if (!runtime.container) return; const observer = new ResizeObserver(() => { - runtime.fitAddon.fit(); - sendResize(); + measureAndResize(runtime); + sendResize(runtime); }); observer.observe(runtime.container); runtime.resizeObserver = observer; @@ -180,7 +227,11 @@ class TerminalRuntimeRegistryImpl { let runtime = this.runtimes.get(paneId); if (runtime) return runtime; - const { terminal, fitAddon, serializeAddon } = createTerminal(); + const savedDims = loadSavedDimensions(paneId); + const cols = savedDims?.cols ?? DEFAULT_COLS; + const rows = savedDims?.rows ?? DEFAULT_ROWS; + + const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); const wrapper = document.createElement("div"); wrapper.style.width = "100%"; @@ -200,6 +251,8 @@ class TerminalRuntimeRegistryImpl { resizeObserver: null, onDataDisposable: null, stateListeners: new Set(), + lastCols: cols, + lastRows: rows, }; this.runtimes.set(paneId, runtime); @@ -211,7 +264,10 @@ class TerminalRuntimeRegistryImpl { runtime.container = container; container.appendChild(runtime.wrapper); - runtime.fitAddon.fit(); + + measureAndResize(runtime); + setupResizeObserver(runtime); + runtime.terminal.focus(); connectSocket(runtime, wsUrl); } @@ -221,6 +277,7 @@ class TerminalRuntimeRegistryImpl { if (!runtime) return; persistBuffer(paneId, runtime.serializeAddon); + persistDimensions(paneId, runtime.lastCols, runtime.lastRows); if (runtime.socket) { runtime.socket.close(); @@ -253,6 +310,7 @@ class TerminalRuntimeRegistryImpl { runtime.terminal.dispose(); runtime.stateListeners.clear(); clearPersistedBuffer(paneId); + clearPersistedDimensions(paneId); this.runtimes.delete(paneId); } From d7d983fd2eb6e15892f211dcb73d32150eb7b55d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Wed, 1 Apr 2026 22:42:38 -0700 Subject: [PATCH 05/10] Separate terminal runtime from WebSocket transport in renderer Split terminal-runtime-registry.ts into three focused modules: - terminal-runtime.ts: XTerm instance, addons, DOM wrapper, buffer/dimension persistence, fit/resize - terminal-ws-transport.ts: WebSocket connection, message protocol, connection state - terminal-runtime-registry.ts: orchestrator mapping paneId to runtime + transport --- .../lib/terminal/terminal-runtime-registry.ts | 353 +++--------------- .../renderer/lib/terminal/terminal-runtime.ts | 177 +++++++++ .../lib/terminal/terminal-ws-transport.ts | 136 +++++++ 3 files changed, 365 insertions(+), 301 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index aa4737010f6..5a6d6ed0500 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -1,338 +1,89 @@ -import { FitAddon } from "@xterm/addon-fit"; -import { SerializeAddon } from "@xterm/addon-serialize"; -import { Terminal as XTerm } from "@xterm/xterm"; - -type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; - -interface TerminalRuntime { - paneId: string; - terminal: XTerm; - fitAddon: FitAddon; - serializeAddon: SerializeAddon; - /** Reparented between containers across attach/detach cycles — not recreated. */ - wrapper: HTMLDivElement; - socket: WebSocket | null; - connectionState: ConnectionState; - container: HTMLDivElement | null; - resizeObserver: ResizeObserver | null; - onDataDisposable: { dispose(): void } | null; - stateListeners: Set<() => void>; - /** Fallback grid size used when the host is not visible. */ - lastCols: number; - lastRows: number; -} - -type TerminalServerMessage = - | { type: "data"; data: string } - | { type: "error"; message: string } - | { type: "exit"; exitCode: number; signal: number } - | { type: "replay"; data: string }; - -const SERIALIZE_SCROLLBACK = 1000; -const STORAGE_KEY_PREFIX = "terminal-buffer:"; -const DIMS_KEY_PREFIX = "terminal-dims:"; -const DEFAULT_COLS = 120; -const DEFAULT_ROWS = 32; - -function createTerminal( - cols: number, - rows: number, -): { - terminal: XTerm; - fitAddon: FitAddon; - serializeAddon: SerializeAddon; -} { - const fitAddon = new FitAddon(); - const serializeAddon = new SerializeAddon(); - const terminal = new XTerm({ - cols, - rows, - cursorBlink: true, - fontFamily: - 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', - fontSize: 12, - theme: { - background: "#14100f", - foreground: "#f5efe9", - }, - }); - terminal.loadAddon(fitAddon); - terminal.loadAddon(serializeAddon); - return { terminal, fitAddon, serializeAddon }; -} - -function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { - try { - const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); - localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); - } catch {} -} - -function restoreBuffer(paneId: string, terminal: XTerm) { - try { - const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); - if (data) terminal.write(data); - } catch {} -} - -function clearPersistedBuffer(paneId: string) { - try { - localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); - } catch {} -} - -function persistDimensions(paneId: string, cols: number, rows: number) { - try { - localStorage.setItem( - `${DIMS_KEY_PREFIX}${paneId}`, - JSON.stringify({ cols, rows }), - ); - } catch {} -} - -function loadSavedDimensions( - paneId: string, -): { cols: number; rows: number } | null { - try { - const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); - if (!raw) return null; - const parsed = JSON.parse(raw); - if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { - return parsed; - } - return null; - } catch { - return null; - } -} - -function clearPersistedDimensions(paneId: string) { - try { - localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); - } catch {} -} - -function hostIsVisible(container: HTMLDivElement | null): boolean { - if (!container) return false; - return container.clientWidth > 0 && container.clientHeight > 0; -} - -function measureAndResize(runtime: TerminalRuntime) { - if (!hostIsVisible(runtime.container)) return; - runtime.fitAddon.fit(); - runtime.lastCols = runtime.terminal.cols; - runtime.lastRows = runtime.terminal.rows; -} - -function sendResize(runtime: TerminalRuntime) { - if (!runtime.socket || runtime.socket.readyState !== WebSocket.OPEN) return; - runtime.socket.send( - JSON.stringify({ - type: "resize", - cols: runtime.terminal.cols, - rows: runtime.terminal.rows, - }), - ); -} - -function setConnectionState(runtime: TerminalRuntime, state: ConnectionState) { - runtime.connectionState = state; - for (const listener of runtime.stateListeners) { - listener(); - } -} - -function connectSocket(runtime: TerminalRuntime, wsUrl: string) { - if (runtime.socket) { - runtime.socket.close(); - runtime.socket = null; - } - - setConnectionState(runtime, "connecting"); - const socket = new WebSocket(wsUrl); - runtime.socket = socket; - - socket.addEventListener("open", () => { - if (runtime.socket !== socket) return; - setConnectionState(runtime, "open"); - sendResize(runtime); - }); - - socket.addEventListener("message", (event) => { - if (runtime.socket !== socket) return; - let message: TerminalServerMessage; - try { - message = JSON.parse(String(event.data)) as TerminalServerMessage; - } catch { - runtime.terminal.writeln("\r\n[terminal] invalid server payload"); - return; - } - - if (message.type === "data" || message.type === "replay") { - runtime.terminal.write(message.data); - return; - } - - if (message.type === "error") { - runtime.terminal.writeln(`\r\n[terminal] ${message.message}`); - return; - } - - if (message.type === "exit") { - runtime.terminal.writeln( - `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, - ); - } - }); - - socket.addEventListener("close", () => { - if (runtime.socket !== socket) return; - setConnectionState(runtime, "closed"); - runtime.socket = null; - }); - - socket.addEventListener("error", () => { - if (runtime.socket !== socket) return; - runtime.terminal.writeln("\r\n[terminal] websocket error"); - }); - - runtime.onDataDisposable?.dispose(); - runtime.onDataDisposable = runtime.terminal.onData((data) => { - if (socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify({ type: "input", data })); - }); -} - -function setupResizeObserver(runtime: TerminalRuntime) { - runtime.resizeObserver?.disconnect(); - if (!runtime.container) return; - - const observer = new ResizeObserver(() => { - measureAndResize(runtime); - sendResize(runtime); - }); - observer.observe(runtime.container); - runtime.resizeObserver = observer; -} - -function teardownResizeObserver(runtime: TerminalRuntime) { - runtime.resizeObserver?.disconnect(); - runtime.resizeObserver = null; +import { + type TerminalRuntime, + attachToContainer, + createRuntime, + detachFromContainer, + disposeRuntime, +} from "./terminal-runtime"; +import { + type ConnectionState, + type TerminalTransport, + connect, + createTransport, + disconnect, + disposeTransport, + sendDispose, + sendResize, +} from "./terminal-ws-transport"; + +interface RegistryEntry { + runtime: TerminalRuntime; + transport: TerminalTransport; } class TerminalRuntimeRegistryImpl { - private runtimes = new Map(); + private entries = new Map(); - getOrCreate(paneId: string): TerminalRuntime { - let runtime = this.runtimes.get(paneId); - if (runtime) return runtime; + private getOrCreate(paneId: string): RegistryEntry { + let entry = this.entries.get(paneId); + if (entry) return entry; - const savedDims = loadSavedDimensions(paneId); - const cols = savedDims?.cols ?? DEFAULT_COLS; - const rows = savedDims?.rows ?? DEFAULT_ROWS; - - const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); - - const wrapper = document.createElement("div"); - wrapper.style.width = "100%"; - wrapper.style.height = "100%"; - terminal.open(wrapper); - restoreBuffer(paneId, terminal); - - runtime = { - paneId, - terminal, - fitAddon, - serializeAddon, - wrapper, - socket: null, - connectionState: "disconnected", - container: null, - resizeObserver: null, - onDataDisposable: null, - stateListeners: new Set(), - lastCols: cols, - lastRows: rows, + entry = { + runtime: createRuntime(paneId), + transport: createTransport(), }; - this.runtimes.set(paneId, runtime); - return runtime; + this.entries.set(paneId, entry); + return entry; } attach(paneId: string, container: HTMLDivElement, wsUrl: string) { - const runtime = this.getOrCreate(paneId); + const { runtime, transport } = this.getOrCreate(paneId); - runtime.container = container; - container.appendChild(runtime.wrapper); + attachToContainer(runtime, container, () => { + sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); + }); - measureAndResize(runtime); - setupResizeObserver(runtime); - - runtime.terminal.focus(); - connectSocket(runtime, wsUrl); + connect(transport, runtime.terminal, wsUrl); } detach(paneId: string) { - const runtime = this.runtimes.get(paneId); - if (!runtime) return; - - persistBuffer(paneId, runtime.serializeAddon); - persistDimensions(paneId, runtime.lastCols, runtime.lastRows); + const entry = this.entries.get(paneId); + if (!entry) return; - if (runtime.socket) { - runtime.socket.close(); - runtime.socket = null; - } - setConnectionState(runtime, "disconnected"); - teardownResizeObserver(runtime); - runtime.onDataDisposable?.dispose(); - runtime.onDataDisposable = null; - runtime.wrapper.remove(); - runtime.container = null; + detachFromContainer(entry.runtime); + disconnect(entry.transport); } dispose(paneId: string) { - const runtime = this.runtimes.get(paneId); - if (!runtime) return; - - if (runtime.socket?.readyState === WebSocket.OPEN) { - runtime.socket.send(JSON.stringify({ type: "dispose" })); - } + const entry = this.entries.get(paneId); + if (!entry) return; - if (runtime.socket) { - runtime.socket.close(); - runtime.socket = null; - } - teardownResizeObserver(runtime); - runtime.onDataDisposable?.dispose(); - runtime.onDataDisposable = null; - runtime.wrapper.remove(); - runtime.terminal.dispose(); - runtime.stateListeners.clear(); - clearPersistedBuffer(paneId); - clearPersistedDimensions(paneId); + sendDispose(entry.transport); + disposeTransport(entry.transport); + disposeRuntime(entry.runtime); - this.runtimes.delete(paneId); + this.entries.delete(paneId); } getAllPaneIds(): Set { - return new Set(this.runtimes.keys()); + return new Set(this.entries.keys()); } has(paneId: string): boolean { - return this.runtimes.has(paneId); + return this.entries.has(paneId); } getConnectionState(paneId: string): ConnectionState { - return this.runtimes.get(paneId)?.connectionState ?? "disconnected"; + return this.entries.get(paneId)?.transport.connectionState ?? "disconnected"; } onStateChange(paneId: string, listener: () => void): () => void { - const runtime = this.runtimes.get(paneId); - if (!runtime) return () => {}; - runtime.stateListeners.add(listener); + const entry = this.entries.get(paneId); + if (!entry) return () => {}; + entry.transport.stateListeners.add(listener); return () => { - runtime.stateListeners.delete(listener); + entry.transport.stateListeners.delete(listener); }; } } diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts new file mode 100644 index 00000000000..25e62912485 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -0,0 +1,177 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as XTerm } from "@xterm/xterm"; + +const SERIALIZE_SCROLLBACK = 1000; +const STORAGE_KEY_PREFIX = "terminal-buffer:"; +const DIMS_KEY_PREFIX = "terminal-dims:"; +const DEFAULT_COLS = 120; +const DEFAULT_ROWS = 32; + +export interface TerminalRuntime { + paneId: string; + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; + /** Reparented between containers across attach/detach cycles — not recreated. */ + wrapper: HTMLDivElement; + container: HTMLDivElement | null; + resizeObserver: ResizeObserver | null; + /** Fallback grid size used when the host is not visible. */ + lastCols: number; + lastRows: number; +} + +function createTerminal( + cols: number, + rows: number, +): { + terminal: XTerm; + fitAddon: FitAddon; + serializeAddon: SerializeAddon; +} { + const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); + const terminal = new XTerm({ + cols, + rows, + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + fontSize: 12, + theme: { + background: "#14100f", + foreground: "#f5efe9", + }, + }); + terminal.loadAddon(fitAddon); + terminal.loadAddon(serializeAddon); + return { terminal, fitAddon, serializeAddon }; +} + +function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { + try { + const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + } catch {} +} + +function restoreBuffer(paneId: string, terminal: XTerm) { + try { + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + if (data) terminal.write(data); + } catch {} +} + +function clearPersistedBuffer(paneId: string) { + try { + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + } catch {} +} + +function persistDimensions(paneId: string, cols: number, rows: number) { + try { + localStorage.setItem( + `${DIMS_KEY_PREFIX}${paneId}`, + JSON.stringify({ cols, rows }), + ); + } catch {} +} + +function loadSavedDimensions( + paneId: string, +): { cols: number; rows: number } | null { + try { + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clearPersistedDimensions(paneId: string) { + try { + localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + } catch {} +} + +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function measureAndResize(runtime: TerminalRuntime) { + if (!hostIsVisible(runtime.container)) return; + runtime.fitAddon.fit(); + runtime.lastCols = runtime.terminal.cols; + runtime.lastRows = runtime.terminal.rows; +} + +export function createRuntime(paneId: string): TerminalRuntime { + const savedDims = loadSavedDimensions(paneId); + const cols = savedDims?.cols ?? DEFAULT_COLS; + const rows = savedDims?.rows ?? DEFAULT_ROWS; + + const { terminal, fitAddon, serializeAddon } = createTerminal(cols, rows); + + const wrapper = document.createElement("div"); + wrapper.style.width = "100%"; + wrapper.style.height = "100%"; + terminal.open(wrapper); + restoreBuffer(paneId, terminal); + + return { + paneId, + terminal, + fitAddon, + serializeAddon, + wrapper, + container: null, + resizeObserver: null, + lastCols: cols, + lastRows: rows, + }; +} + +export function attachToContainer( + runtime: TerminalRuntime, + container: HTMLDivElement, + onResize?: () => void, +) { + runtime.container = container; + container.appendChild(runtime.wrapper); + measureAndResize(runtime); + + runtime.resizeObserver?.disconnect(); + const observer = new ResizeObserver(() => { + measureAndResize(runtime); + onResize?.(); + }); + observer.observe(container); + runtime.resizeObserver = observer; + + runtime.terminal.focus(); +} + +export function detachFromContainer(runtime: TerminalRuntime) { + persistBuffer(runtime.paneId, runtime.serializeAddon); + persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.container = null; +} + +export function disposeRuntime(runtime: TerminalRuntime) { + runtime.resizeObserver?.disconnect(); + runtime.resizeObserver = null; + runtime.wrapper.remove(); + runtime.terminal.dispose(); + clearPersistedBuffer(runtime.paneId); + clearPersistedDimensions(runtime.paneId); +} diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts new file mode 100644 index 00000000000..872e2481a72 --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -0,0 +1,136 @@ +import type { Terminal as XTerm } from "@xterm/xterm"; + +export type ConnectionState = "disconnected" | "connecting" | "open" | "closed"; + +type TerminalServerMessage = + | { type: "data"; data: string } + | { type: "error"; message: string } + | { type: "exit"; exitCode: number; signal: number } + | { type: "replay"; data: string }; + +export interface TerminalTransport { + socket: WebSocket | null; + connectionState: ConnectionState; + onDataDisposable: { dispose(): void } | null; + stateListeners: Set<() => void>; +} + +function setConnectionState( + transport: TerminalTransport, + state: ConnectionState, +) { + transport.connectionState = state; + for (const listener of transport.stateListeners) { + listener(); + } +} + +export function createTransport(): TerminalTransport { + return { + socket: null, + connectionState: "disconnected", + onDataDisposable: null, + stateListeners: new Set(), + }; +} + +export function connect( + transport: TerminalTransport, + terminal: XTerm, + wsUrl: string, +) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + + setConnectionState(transport, "connecting"); + const socket = new WebSocket(wsUrl); + transport.socket = socket; + + socket.addEventListener("open", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "open"); + sendResize(transport, terminal.cols, terminal.rows); + }); + + socket.addEventListener("message", (event) => { + if (transport.socket !== socket) return; + let message: TerminalServerMessage; + try { + message = JSON.parse(String(event.data)) as TerminalServerMessage; + } catch { + terminal.writeln("\r\n[terminal] invalid server payload"); + return; + } + + if (message.type === "data" || message.type === "replay") { + terminal.write(message.data); + return; + } + + if (message.type === "error") { + terminal.writeln(`\r\n[terminal] ${message.message}`); + return; + } + + if (message.type === "exit") { + terminal.writeln( + `\r\n[terminal] exited with code ${message.exitCode} (signal ${message.signal})`, + ); + } + }); + + socket.addEventListener("close", () => { + if (transport.socket !== socket) return; + setConnectionState(transport, "closed"); + transport.socket = null; + }); + + socket.addEventListener("error", () => { + if (transport.socket !== socket) return; + terminal.writeln("\r\n[terminal] websocket error"); + }); + + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = terminal.onData((data) => { + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "input", data })); + }); +} + +export function disconnect(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + setConnectionState(transport, "disconnected"); + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; +} + +export function sendResize( + transport: TerminalTransport, + cols: number, + rows: number, +) { + if (!transport.socket || transport.socket.readyState !== WebSocket.OPEN) + return; + transport.socket.send(JSON.stringify({ type: "resize", cols, rows })); +} + +export function sendDispose(transport: TerminalTransport) { + if (transport.socket?.readyState === WebSocket.OPEN) { + transport.socket.send(JSON.stringify({ type: "dispose" })); + } +} + +export function disposeTransport(transport: TerminalTransport) { + if (transport.socket) { + transport.socket.close(); + transport.socket = null; + } + transport.onDataDisposable?.dispose(); + transport.onDataDisposable = null; + transport.stateListeners.clear(); +} From 5a2c4dcfe3eda313a9d4d2f50b0acd55f3a45fab Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 09:37:41 -0700 Subject: [PATCH 06/10] Fix stale WebSocket handlers and eager runtime creation - Guard onClose/onError/onMessage with socket reference comparison to prevent displaced connections from corrupting active session state - Close old socket explicitly on reattach (code 4000) - Create registry entry eagerly in onStateChange so useSyncExternalStore listeners are registered before the attach effect runs --- .../lib/terminal/terminal-runtime-registry.ts | 7 +++---- .../host-service/src/terminal/terminal.ts | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index 5a6d6ed0500..f5cc08eeaeb 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -79,11 +79,10 @@ class TerminalRuntimeRegistryImpl { } onStateChange(paneId: string, listener: () => void): () => void { - const entry = this.entries.get(paneId); - if (!entry) return () => {}; - entry.transport.stateListeners.add(listener); + const { transport } = this.getOrCreate(paneId); + transport.stateListeners.add(listener); return () => { - entry.transport.stateListeners.delete(listener); + transport.stateListeners.delete(listener); }; } } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 73af3171d29..24e66c5c65f 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -29,7 +29,11 @@ const MAX_BUFFER_BYTES = 64 * 1024; interface TerminalSession { paneId: string; pty: IPty; - socket: { send: (data: string) => void; readyState: number } | null; + socket: { + send: (data: string) => void; + close: (code?: number, reason?: string) => void; + readyState: number; + } | null; buffer: string[]; bufferBytes: number; exited: boolean; @@ -114,6 +118,9 @@ export function registerWorkspaceTerminalRoute({ const existing = sessions.get(paneId); if (existing) { + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); + } existing.socket = ws; replayBuffer(existing, ws); if (existing.exited) { @@ -210,9 +217,9 @@ export function registerWorkspaceTerminalRoute({ }); }, - onMessage: (event, _ws) => { + onMessage: (event, ws) => { const session = sessions.get(paneId ?? ""); - if (!session) return; + if (!session || session.socket !== ws) return; let message: TerminalClientMessage; try { @@ -248,16 +255,16 @@ export function registerWorkspaceTerminalRoute({ } }, - onClose: () => { + onClose: (_event, ws) => { const session = sessions.get(paneId ?? ""); - if (session) { + if (session?.socket === ws) { session.socket = null; } }, - onError: () => { + onError: (_event, ws) => { const session = sessions.get(paneId ?? ""); - if (session) { + if (session?.socket === ws) { session.socket = null; } }, From be505e45ca49d27462b045ba41405545afc5e9fd Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 09:43:34 -0700 Subject: [PATCH 07/10] Co-locate useGlobalTerminalLifecycle under its component Move hook from routes/_authenticated/hooks/ into components/GlobalTerminalLifecycle/hooks/ since it's only used by that component. --- .../GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx | 2 +- .../hooks/useGlobalTerminalLifecycle/index.ts | 0 .../useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/desktop/src/renderer/routes/_authenticated/{ => components/GlobalTerminalLifecycle}/hooks/useGlobalTerminalLifecycle/index.ts (100%) rename apps/desktop/src/renderer/routes/_authenticated/{ => components/GlobalTerminalLifecycle}/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts (96%) diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx index 5e30ebdf848..a5fe4668d22 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/GlobalTerminalLifecycle.tsx @@ -1,4 +1,4 @@ -import { useGlobalTerminalLifecycle } from "../../hooks/useGlobalTerminalLifecycle"; +import { useGlobalTerminalLifecycle } from "./hooks/useGlobalTerminalLifecycle"; export function GlobalTerminalLifecycle() { useGlobalTerminalLifecycle(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts similarity index 100% rename from apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/index.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts similarity index 96% rename from apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts rename to apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index 183b415060f..e6053cb7943 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -2,7 +2,7 @@ import type { WorkspaceState } from "@superset/panes"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; -import { useCollections } from "../../providers/CollectionsProvider"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; /** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ const DISPOSE_DELAY_MS = 500; From 4acf790907bcd6712e852048e1f5e50b92a5b441 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 10:09:57 -0700 Subject: [PATCH 08/10] Keep WebSocket alive across terminal attach/detach cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detach() now only removes the DOM wrapper, resize observer, and focus — the WebSocket and xterm data flow stay alive so output written while the pane is hidden (tab switch, workspace switch) is not lost. attach() checks whether the transport is already open for the same URL and skips reconnection on simple re-shows, only reconnecting when the socket dropped or the endpoint changed. A full terminal.refresh() is added on re-attach to repaint rows that were written while the canvas was offscreen. --- .../lib/terminal/terminal-runtime-registry.ts | 23 ++++++++++++++++--- .../renderer/lib/terminal/terminal-runtime.ts | 4 ++++ .../lib/terminal/terminal-ws-transport.ts | 6 +++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index f5cc08eeaeb..d7adef4e1f8 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -10,7 +10,6 @@ import { type TerminalTransport, connect, createTransport, - disconnect, disposeTransport, sendDispose, sendResize, @@ -44,15 +43,33 @@ class TerminalRuntimeRegistryImpl { sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); }); - connect(transport, runtime.terminal, wsUrl); + // Only connect/reconnect when the socket is not already open for this URL. + // On a simple tab-switch the transport stays alive, so we skip the reconnect + // and just send a resize to sync dimensions. + const isAlreadyConnected = + transport.connectionState === "open" && transport.currentUrl === wsUrl; + + if (isAlreadyConnected) { + sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); + } else { + connect(transport, runtime.terminal, wsUrl); + } } + /** + * Detach the terminal from its DOM container. + * + * This only removes the DOM attachment (wrapper, resize observer, focus). + * The WebSocket and xterm data flow are intentionally kept alive so output + * written while the pane is hidden is not lost. Disposal of the transport + * happens exclusively through {@link dispose} when the paneId is removed + * from persisted pane state. + */ detach(paneId: string) { const entry = this.entries.get(paneId); if (!entry) return; detachFromContainer(entry.runtime); - disconnect(entry.transport); } dispose(paneId: string) { diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 25e62912485..484a44d635f 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -147,6 +147,10 @@ export function attachToContainer( container.appendChild(runtime.wrapper); measureAndResize(runtime); + // Force a full repaint — the renderer may have skipped paint frames while + // the wrapper was detached from the DOM and receiving background data. + runtime.terminal.refresh(0, runtime.terminal.rows - 1); + runtime.resizeObserver?.disconnect(); const observer = new ResizeObserver(() => { measureAndResize(runtime); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 872e2481a72..81d229c562d 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -11,6 +11,8 @@ type TerminalServerMessage = export interface TerminalTransport { socket: WebSocket | null; connectionState: ConnectionState; + /** The URL the socket is currently connected (or connecting) to. */ + currentUrl: string | null; onDataDisposable: { dispose(): void } | null; stateListeners: Set<() => void>; } @@ -29,6 +31,7 @@ export function createTransport(): TerminalTransport { return { socket: null, connectionState: "disconnected", + currentUrl: null, onDataDisposable: null, stateListeners: new Set(), }; @@ -44,6 +47,7 @@ export function connect( transport.socket = null; } + transport.currentUrl = wsUrl; setConnectionState(transport, "connecting"); const socket = new WebSocket(wsUrl); transport.socket = socket; @@ -104,6 +108,7 @@ export function disconnect(transport: TerminalTransport) { transport.socket.close(); transport.socket = null; } + transport.currentUrl = null; setConnectionState(transport, "disconnected"); transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; @@ -130,6 +135,7 @@ export function disposeTransport(transport: TerminalTransport) { transport.socket.close(); transport.socket = null; } + transport.currentUrl = null; transport.onDataDisposable?.dispose(); transport.onDataDisposable = null; transport.stateListeners.clear(); From 7836a3060b87db4ad491c85bf67c01fa57d7ac5e Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 10:19:01 -0700 Subject: [PATCH 09/10] Lint --- .../renderer/lib/terminal/terminal-runtime-registry.ts | 8 +++++--- .../components/TerminalPane/TerminalPane.tsx | 5 ++--- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 5 +---- .../useGlobalTerminalLifecycle.ts | 4 +--- packages/host-service/src/terminal/terminal.ts | 4 +--- 5 files changed, 10 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index d7adef4e1f8..ca25cf1a95f 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -1,18 +1,18 @@ import { - type TerminalRuntime, attachToContainer, createRuntime, detachFromContainer, disposeRuntime, + type TerminalRuntime, } from "./terminal-runtime"; import { type ConnectionState, - type TerminalTransport, connect, createTransport, disposeTransport, sendDispose, sendResize, + type TerminalTransport, } from "./terminal-ws-transport"; interface RegistryEntry { @@ -92,7 +92,9 @@ class TerminalRuntimeRegistryImpl { } getConnectionState(paneId: string): ConnectionState { - return this.entries.get(paneId)?.transport.connectionState ?? "disconnected"; + return ( + this.entries.get(paneId)?.transport.connectionState ?? "disconnected" + ); } onStateChange(paneId: string, listener: () => void): () => void { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 0ca024127cf..dffa3e4810d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -27,9 +27,8 @@ export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { workspaceId, }); - const connectionState = useSyncExternalStore( - subscribeToState(paneId), - () => getConnectionState(paneId), + const connectionState = useSyncExternalStore(subscribeToState(paneId), () => + getConnectionState(paneId), ); useEffect(() => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index f1a1a646764..d8331c359b8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -41,10 +41,7 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), }, browser: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index e6053cb7943..7a3e528c301 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -7,9 +7,7 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect /** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ const DISPOSE_DELAY_MS = 500; -function extractTerminalPaneIds( - rows: { paneLayout: unknown }[], -): Set { +function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { const ids = new Set(); for (const row of rows) { const layout = row.paneLayout as WorkspaceState | undefined; diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 24e66c5c65f..1e917419791 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -223,9 +223,7 @@ export function registerWorkspaceTerminalRoute({ let message: TerminalClientMessage; try { - message = JSON.parse( - String(event.data), - ) as TerminalClientMessage; + message = JSON.parse(String(event.data)) as TerminalClientMessage; } catch { if (session.socket) { sendMessage(session.socket, { From 3259e181f116cb9a5e336a6bfd1b89b5c32e4078 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 10:19:42 -0700 Subject: [PATCH 10/10] Move connect idempotency into transport layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connect() now early-returns when already open or connecting to the same URL, covering rapid tab-switch during an in-flight handshake. The registry no longer branches — it just calls connect() unconditionally. --- .../lib/terminal/terminal-runtime-registry.ts | 12 +----------- .../renderer/lib/terminal/terminal-ws-transport.ts | 6 ++++++ 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index ca25cf1a95f..d6551f6b857 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -43,17 +43,7 @@ class TerminalRuntimeRegistryImpl { sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); }); - // Only connect/reconnect when the socket is not already open for this URL. - // On a simple tab-switch the transport stays alive, so we skip the reconnect - // and just send a resize to sync dimensions. - const isAlreadyConnected = - transport.connectionState === "open" && transport.currentUrl === wsUrl; - - if (isAlreadyConnected) { - sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); - } else { - connect(transport, runtime.terminal, wsUrl); - } + connect(transport, runtime.terminal, wsUrl); } /** diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 81d229c562d..e522a0b8304 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -42,6 +42,12 @@ export function connect( terminal: XTerm, wsUrl: string, ) { + // Idempotent: skip if already connected/connecting to the same endpoint. + const isActive = + transport.connectionState === "open" || + transport.connectionState === "connecting"; + if (isActive && transport.currentUrl === wsUrl) return; + if (transport.socket) { transport.socket.close(); transport.socket = null;