From af65234c8ed3ba20f014fa725787adeebe13740d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 21:03:27 -0700 Subject: [PATCH 1/9] Decouple paneId and terminalId --- .../lib/terminal/terminal-runtime-registry.ts | 38 +- .../renderer/lib/terminal/terminal-runtime.ts | 42 +- .../components/TerminalPane/TerminalPane.tsx | 63 ++- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 13 +- .../useV2WorkspacePaneLayout.ts | 62 ++- .../v2-workspace/$workspaceId/page.tsx | 12 +- .../v2-workspace/$workspaceId/types.ts | 5 +- .../useGlobalTerminalLifecycle.ts | 72 ++- .../desktop/src/renderer/stores/tabs/store.ts | 29 +- .../drizzle/0002_add_terminal_sessions.sql | 16 + .../drizzle/meta/0002_snapshot.json | 499 ++++++++++++++++++ .../host-service/drizzle/meta/_journal.json | 7 + packages/host-service/src/db/schema.ts | 24 + .../host-service/src/terminal/terminal.ts | 280 ++++++---- 14 files changed, 947 insertions(+), 215 deletions(-) create mode 100644 packages/host-service/drizzle/0002_add_terminal_sessions.sql create mode 100644 packages/host-service/drizzle/meta/0002_snapshot.json 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 d6551f6b857..acd7d01d906 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -23,21 +23,21 @@ interface RegistryEntry { class TerminalRuntimeRegistryImpl { private entries = new Map(); - private getOrCreate(paneId: string): RegistryEntry { - let entry = this.entries.get(paneId); + private getOrCreate(terminalId: string): RegistryEntry { + let entry = this.entries.get(terminalId); if (entry) return entry; entry = { - runtime: createRuntime(paneId), + runtime: createRuntime(terminalId), transport: createTransport(), }; - this.entries.set(paneId, entry); + this.entries.set(terminalId, entry); return entry; } - attach(paneId: string, container: HTMLDivElement, wsUrl: string) { - const { runtime, transport } = this.getOrCreate(paneId); + attach(terminalId: string, container: HTMLDivElement, wsUrl: string) { + const { runtime, transport } = this.getOrCreate(terminalId); attachToContainer(runtime, container, () => { sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); @@ -52,43 +52,43 @@ class TerminalRuntimeRegistryImpl { * 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 + * happens exclusively through {@link dispose} when the terminalId is removed * from persisted pane state. */ - detach(paneId: string) { - const entry = this.entries.get(paneId); + detach(terminalId: string) { + const entry = this.entries.get(terminalId); if (!entry) return; detachFromContainer(entry.runtime); } - dispose(paneId: string) { - const entry = this.entries.get(paneId); + dispose(terminalId: string) { + const entry = this.entries.get(terminalId); if (!entry) return; sendDispose(entry.transport); disposeTransport(entry.transport); disposeRuntime(entry.runtime); - this.entries.delete(paneId); + this.entries.delete(terminalId); } - getAllPaneIds(): Set { + getAllTerminalIds(): Set { return new Set(this.entries.keys()); } - has(paneId: string): boolean { - return this.entries.has(paneId); + has(terminalId: string): boolean { + return this.entries.has(terminalId); } - getConnectionState(paneId: string): ConnectionState { + getConnectionState(terminalId: string): ConnectionState { return ( - this.entries.get(paneId)?.transport.connectionState ?? "disconnected" + this.entries.get(terminalId)?.transport.connectionState ?? "disconnected" ); } - onStateChange(paneId: string, listener: () => void): () => void { - const { transport } = this.getOrCreate(paneId); + onStateChange(terminalId: string, listener: () => void): () => void { + const { transport } = this.getOrCreate(terminalId); transport.stateListeners.add(listener); return () => { 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 index 484a44d635f..9e7e4499ccd 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -9,7 +9,7 @@ const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; export interface TerminalRuntime { - paneId: string; + terminalId: string; terminal: XTerm; fitAddon: FitAddon; serializeAddon: SerializeAddon; @@ -49,40 +49,40 @@ function createTerminal( return { terminal, fitAddon, serializeAddon }; } -function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { +function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { try { const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); - localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${terminalId}`, data); } catch {} } -function restoreBuffer(paneId: string, terminal: XTerm) { +function restoreBuffer(terminalId: string, terminal: XTerm) { try { - const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); if (data) terminal.write(data); } catch {} } -function clearPersistedBuffer(paneId: string) { +function clearPersistedBuffer(terminalId: string) { try { - localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${terminalId}`); } catch {} } -function persistDimensions(paneId: string, cols: number, rows: number) { +function persistDimensions(terminalId: string, cols: number, rows: number) { try { localStorage.setItem( - `${DIMS_KEY_PREFIX}${paneId}`, + `${DIMS_KEY_PREFIX}${terminalId}`, JSON.stringify({ cols, rows }), ); } catch {} } function loadSavedDimensions( - paneId: string, + terminalId: string, ): { cols: number; rows: number } | null { try { - const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${terminalId}`); if (!raw) return null; const parsed = JSON.parse(raw); if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { @@ -94,9 +94,9 @@ function loadSavedDimensions( } } -function clearPersistedDimensions(paneId: string) { +function clearPersistedDimensions(terminalId: string) { try { - localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + localStorage.removeItem(`${DIMS_KEY_PREFIX}${terminalId}`); } catch {} } @@ -112,8 +112,8 @@ function measureAndResize(runtime: TerminalRuntime) { runtime.lastRows = runtime.terminal.rows; } -export function createRuntime(paneId: string): TerminalRuntime { - const savedDims = loadSavedDimensions(paneId); +export function createRuntime(terminalId: string): TerminalRuntime { + const savedDims = loadSavedDimensions(terminalId); const cols = savedDims?.cols ?? DEFAULT_COLS; const rows = savedDims?.rows ?? DEFAULT_ROWS; @@ -123,10 +123,10 @@ export function createRuntime(paneId: string): TerminalRuntime { wrapper.style.width = "100%"; wrapper.style.height = "100%"; terminal.open(wrapper); - restoreBuffer(paneId, terminal); + restoreBuffer(terminalId, terminal); return { - paneId, + terminalId, terminal, fitAddon, serializeAddon, @@ -163,8 +163,8 @@ export function attachToContainer( } export function detachFromContainer(runtime: TerminalRuntime) { - persistBuffer(runtime.paneId, runtime.serializeAddon); - persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); @@ -176,6 +176,6 @@ export function disposeRuntime(runtime: TerminalRuntime) { runtime.resizeObserver = null; runtime.wrapper.remove(); runtime.terminal.dispose(); - clearPersistedBuffer(runtime.paneId); - clearPersistedDimensions(runtime.paneId); + clearPersistedBuffer(runtime.terminalId); + clearPersistedDimensions(runtime.terminalId); } 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 dffa3e4810d..b7dd3452b4a 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,46 +1,81 @@ import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { type ConnectionState, terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; -import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { + useWorkspaceHostUrl, + useWorkspaceWsUrl, +} from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; interface TerminalPaneProps { - paneId: string; + terminalId: string; workspaceId: string; } -function subscribeToState(paneId: string) { +function subscribeToState(terminalId: string) { return (callback: () => void) => - terminalRuntimeRegistry.onStateChange(paneId, callback); + terminalRuntimeRegistry.onStateChange(terminalId, callback); } -function getConnectionState(paneId: string): ConnectionState { - return terminalRuntimeRegistry.getConnectionState(paneId); +function getConnectionState(terminalId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { +export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); + const hostUrl = useWorkspaceHostUrl(); + const [sessionReady, setSessionReady] = useState(false); + const createAttemptedRef = useRef(false); - const websocketUrl = useWorkspaceWsUrl(`/terminal/${paneId}`, { + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, }); - const connectionState = useSyncExternalStore(subscribeToState(paneId), () => - getConnectionState(paneId), + const connectionState = useSyncExternalStore( + subscribeToState(terminalId), + () => getConnectionState(terminalId), ); + // Create the terminal session in host-service before attaching via websocket + useEffect(() => { + if (createAttemptedRef.current) return; + createAttemptedRef.current = true; + + fetch(new URL("/terminal/sessions", hostUrl).href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + terminalId, + workspaceId, + }), + }) + .then((res) => { + if (!res.ok) { + return res.json().then((body) => { + console.error("[TerminalPane] session create failed:", body); + }); + } + setSessionReady(true); + }) + .catch((err) => { + console.error("[TerminalPane] session create error:", err); + }); + }, [terminalId, workspaceId, hostUrl]); + + // Attach to the terminal runtime only after the session has been created useEffect(() => { + if (!sessionReady) return; const container = containerRef.current; if (!container) return; - terminalRuntimeRegistry.attach(paneId, container, websocketUrl); + terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); return () => { - terminalRuntimeRegistry.detach(paneId); + terminalRuntimeRegistry.detach(terminalId); }; - }, [paneId, websocketUrl]); + }, [terminalId, websocketUrl, sessionReady]); return (
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 d210db7fa6b..07b64b2ba72 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 @@ -9,6 +9,7 @@ import type { DevtoolsPaneData, FilePaneData, PaneViewerData, + TerminalPaneData, } from "../../types"; import { ChatPane } from "./components/ChatPane"; import { FilePane } from "./components/FilePane"; @@ -82,9 +83,15 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: (ctx: RendererContext) => ( - - ), + renderPane: (ctx: RendererContext) => { + const data = ctx.pane.data as TerminalPaneData; + return ( + + ); + }, }, browser: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts index 088c332ccda..9b2b101d39c 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts @@ -1,10 +1,14 @@ -import { createWorkspaceStore, type WorkspaceState } from "@superset/panes"; +import { + createWorkspaceStore, + type Pane, + type WorkspaceState, +} from "@superset/panes"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useMemo, useRef, useState } from "react"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { PaneViewerData } from "../../types"; +import type { PaneViewerData, TerminalPaneData } from "../../types"; const EMPTY_STATE: WorkspaceState = { version: 1, @@ -12,6 +16,39 @@ const EMPTY_STATE: WorkspaceState = { activeTabId: null, }; +interface LegacyTerminalPaneData { + sessionKey?: string; + cwd?: string; + launchMode?: string; + command?: string; + terminalId?: string; +} + +/** + * Migrate legacy terminal pane data to the new {terminalId} shape. + * Old panes had {sessionKey, cwd, launchMode, command?} — convert them + * in-place so the renderer always sees {terminalId}. + * Returns true if any pane was migrated (caller should persist). + */ +function migrateTerminalPaneData( + state: WorkspaceState, +): boolean { + let migrated = false; + for (const tab of state.tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.kind !== "terminal") continue; + const data = pane.data as unknown as LegacyTerminalPaneData; + if (data.terminalId) continue; + // Legacy pane — assign a new terminalId + (pane as Pane).data = { + terminalId: crypto.randomUUID(), + } as TerminalPaneData as PaneViewerData; + migrated = true; + } + } + return migrated; +} + function getSnapshot(state: WorkspaceState): string { return JSON.stringify(state); } @@ -44,13 +81,24 @@ export function useV2WorkspacePaneLayout({ [collections, workspaceId], ); const localWorkspaceState = localWorkspaceRows[0] ?? null; - const persistedPaneLayout = useMemo( - () => + const persistedPaneLayout = useMemo(() => { + const layout = (localWorkspaceState?.paneLayout as | WorkspaceState - | undefined) ?? EMPTY_STATE, - [localWorkspaceState], - ); + | undefined) ?? EMPTY_STATE; + + // Migrate legacy terminal panes ({sessionKey, cwd, …} → {terminalId}) + if (layout !== EMPTY_STATE && migrateTerminalPaneData(layout)) { + // Persist the migrated layout back so the migration only runs once + if (collections.v2WorkspaceLocalState.get(workspaceId)) { + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.paneLayout = layout; + }); + } + } + + return layout; + }, [localWorkspaceState, collections, workspaceId]); useEffect(() => { ensureWorkspaceInSidebar(workspaceId, projectId); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 901487c56d0..05881b41682 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -150,14 +150,12 @@ function WorkspaceContent({ { kind: "terminal", data: { - sessionKey: `${workspaceId}:${crypto.randomUUID()}`, - cwd: `/workspace/${workspaceName}`, - launchMode: "workspace-shell", + terminalId: crypto.randomUUID(), } as TerminalPaneData, }, ], }); - }, [store, workspaceId, workspaceName]); + }, [store]); const addChatTab = useCallback(() => { store.getState().addTab({ @@ -225,9 +223,7 @@ function WorkspaceContent({ ctx.actions.split(position, { kind: "terminal", data: { - sessionKey: `${workspaceId}:${crypto.randomUUID()}`, - cwd: `/workspace/${workspaceName}`, - launchMode: "workspace-shell", + terminalId: crypto.randomUUID(), } as TerminalPaneData, }); }, @@ -241,7 +237,7 @@ function WorkspaceContent({ onClick: (ctx) => ctx.actions.close(), }, ], - [workspaceId, workspaceName], + [], ); const collections = useCollections(); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index a0f17ea894b..a2cc6d53a04 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -6,10 +6,7 @@ export interface FilePaneData { } export interface TerminalPaneData { - sessionKey: string; - cwd: string; - launchMode: "workspace-shell" | "command" | "agent"; - command?: string; + terminalId: string; } export interface ChatPaneData { 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 7a3e528c301..0c09d4cccae 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 @@ -4,18 +4,28 @@ import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; 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; +/** + * Cross-workspace moves temporarily remove a terminalId then re-add it. + * Wait before detaching the renderer runtime. + */ +const DETACH_DELAY_MS = 500; -function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { +interface TerminalPaneData { + terminalId: string; +} + +function extractTerminalIds(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)) { + for (const pane of Object.values(tab.panes)) { if (pane.kind === "terminal") { - ids.add(paneId); + const data = pane.data as TerminalPaneData; + if (data.terminalId) { + ids.add(data.terminalId); + } } } } @@ -23,10 +33,19 @@ function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { return ids; } +/** + * Manages renderer-side terminal runtime lifecycle. + * + * When a terminal pane is removed from workspace state, the renderer runtime + * (xterm + DOM wrapper) is detached but NOT disposed. The terminal session + * in host-service stays alive independently — pane removal does not kill + * the terminal. Only an explicit dispose action (e.g. user kills terminal) + * should call terminalRuntimeRegistry.dispose(). + */ export function useGlobalTerminalLifecycle() { const collections = useCollections(); - const prevPaneIdsRef = useRef>(new Set()); - const pendingDisposals = useRef>>( + const prevTerminalIdsRef = useRef>(new Set()); + const pendingDetaches = useRef>>( new Map(), ); @@ -39,46 +58,51 @@ export function useGlobalTerminalLifecycle() { ); useEffect(() => { - const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); - const prevPaneIds = prevPaneIdsRef.current; + const currentTerminalIds = extractTerminalIds(allWorkspaceRows); + const prevTerminalIds = prevTerminalIdsRef.current; - for (const paneId of currentPaneIds) { - const timer = pendingDisposals.current.get(paneId); + // Cancel pending detach for terminals that reappeared (cross-workspace move) + for (const terminalId of currentTerminalIds) { + const timer = pendingDetaches.current.get(terminalId); if (timer) { clearTimeout(timer); - pendingDisposals.current.delete(paneId); + pendingDetaches.current.delete(terminalId); } } - for (const paneId of prevPaneIds) { - if (currentPaneIds.has(paneId)) continue; - if (pendingDisposals.current.has(paneId)) continue; + // Schedule detach (not dispose) for terminals whose pane was removed + for (const terminalId of prevTerminalIds) { + if (currentTerminalIds.has(terminalId)) continue; + if (pendingDetaches.current.has(terminalId)) continue; const timer = setTimeout(() => { - pendingDisposals.current.delete(paneId); + pendingDetaches.current.delete(terminalId); const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ); - const freshIds = extractTerminalPaneIds(freshRows); + const freshIds = extractTerminalIds(freshRows); - if (!freshIds.has(paneId)) { - terminalRuntimeRegistry.dispose(paneId); + if (!freshIds.has(terminalId)) { + // Detach renderer runtime only — terminal session stays alive + // in host-service. The xterm instance and DOM wrapper are kept + // so a future pane can reattach without losing scrollback. + terminalRuntimeRegistry.detach(terminalId); } - }, DISPOSE_DELAY_MS); + }, DETACH_DELAY_MS); - pendingDisposals.current.set(paneId, timer); + pendingDetaches.current.set(terminalId, timer); } - prevPaneIdsRef.current = currentPaneIds; + prevTerminalIdsRef.current = currentTerminalIds; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDisposals.current.values()) { + for (const timer of pendingDetaches.current.values()) { clearTimeout(timer); } - pendingDisposals.current.clear(); + pendingDetaches.current.clear(); }; }, []); } diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 82c893bf794..cd53cc86535 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -756,17 +756,18 @@ export const useTabsStore = create()( const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); const reuseExisting = options.reuseExisting ?? "workspace"; - const existingFileViewerPane = reuseExisting !== "none" - ? findReusableFileViewerPane({ - workspaceId, - activeTabId: activeTab.id, - tabs: state.tabs, - panes: state.panes, - tabHistoryStacks: state.tabHistoryStacks, - reuseExisting, - options, - }) - : null; + const existingFileViewerPane = + reuseExisting !== "none" + ? findReusableFileViewerPane({ + workspaceId, + activeTabId: activeTab.id, + tabs: state.tabs, + panes: state.panes, + tabHistoryStacks: state.tabHistoryStacks, + reuseExisting, + options, + }) + : null; if (existingFileViewerPane) { const nextPane = applyFileViewerOpenOptionsToPane( @@ -826,7 +827,11 @@ export const useTabsStore = create()( // If we found an unpinned (preview) file-viewer pane, reuse it // (skip reuse when explicitly requesting a new tab, e.g. cmd+click) - if (fileViewerPanes.length > 0 && !options.openInNewTab && reuseExisting !== "none") { + if ( + fileViewerPanes.length > 0 && + !options.openInNewTab && + reuseExisting !== "none" + ) { const paneToReuse = fileViewerPanes[0]; const existingFileViewer = paneToReuse.fileViewer; if (!existingFileViewer) { diff --git a/packages/host-service/drizzle/0002_add_terminal_sessions.sql b/packages/host-service/drizzle/0002_add_terminal_sessions.sql new file mode 100644 index 00000000000..60954cfecfc --- /dev/null +++ b/packages/host-service/drizzle/0002_add_terminal_sessions.sql @@ -0,0 +1,16 @@ +CREATE TABLE `terminal_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `workspace_id` text, + `cwd` text NOT NULL, + `shell` text NOT NULL, + `launch_mode` text DEFAULT 'workspace-shell' NOT NULL, + `command` text, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `last_attached_at` integer, + `ended_at` integer, + FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `terminal_sessions_workspace_id_idx` ON `terminal_sessions` (`workspace_id`);--> statement-breakpoint +CREATE INDEX `terminal_sessions_status_idx` ON `terminal_sessions` (`status`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0002_snapshot.json b/packages/host-service/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000000..a70c618a15e --- /dev/null +++ b/packages/host-service/drizzle/meta/0002_snapshot.json @@ -0,0 +1,499 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "79567b7e-1bbb-4654-97e9-a54b29d33205", + "prevId": "f5887d62-b5ad-4441-9648-d4456be25acb", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cwd": { + "name": "cwd", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "shell": { + "name": "shell", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "launch_mode": { + "name": "launch_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'workspace-shell'" + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_workspace_id_idx": { + "name": "terminal_sessions_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_branch_idx": { + "name": "workspaces_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index af209bd1b35..6bb4b5f1a96 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1773705162679, "tag": "0001_famous_mindworm", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1775186329285, + "tag": "0002_add_terminal_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index d6b88c7f18a..a66cf14138f 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -6,6 +6,30 @@ import { uniqueIndex, } from "drizzle-orm/sqlite-core"; +export const terminalSessions = sqliteTable( + "terminal_sessions", + { + id: text().primaryKey(), + workspaceId: text("workspace_id").references(() => workspaces.id, { + onDelete: "set null", + }), + cwd: text().notNull(), + shell: text().notNull(), + launchMode: text("launch_mode").notNull().default("workspace-shell"), + command: text(), + status: text().notNull().default("active"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + lastAttachedAt: integer("last_attached_at"), + endedAt: integer("ended_at"), + }, + (table) => [ + index("terminal_sessions_workspace_id_idx").on(table.workspaceId), + index("terminal_sessions_status_idx").on(table.status), + ], +); + export const projects = sqliteTable( "projects", { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 1e917419791..6b8d0f31c50 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -5,7 +5,7 @@ import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; -import { workspaces } from "../db/schema"; +import { terminalSessions, workspaces } from "../db/schema"; interface RegisterWorkspaceTerminalRouteOptions { app: Hono; @@ -27,7 +27,7 @@ type TerminalServerMessage = const MAX_BUFFER_BYTES = 64 * 1024; interface TerminalSession { - paneId: string; + terminalId: string; pty: IPty; socket: { send: (data: string) => void; @@ -80,8 +80,8 @@ function replayBuffer( sendMessage(socket, { type: "replay", data: combined }); } -function disposeSession(paneId: string) { - const session = sessions.get(paneId); +function disposeSession(terminalId: string, db: HostDb) { + const session = sessions.get(terminalId); if (!session) return; if (!session.exited) { @@ -91,7 +91,123 @@ function disposeSession(paneId: string) { // PTY may already be dead } } - sessions.delete(paneId); + sessions.delete(terminalId); + + db.update(terminalSessions) + .set({ status: "disposed", endedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); +} + +interface CreateTerminalSessionOptions { + terminalId: string; + workspaceId: string; + db: HostDb; + launchMode?: string; + command?: string; +} + +function createTerminalSessionInternal({ + terminalId, + workspaceId, + db, + launchMode = "workspace-shell", + command, +}: CreateTerminalSessionOptions): TerminalSession | { error: string } { + const existing = sessions.get(terminalId); + if (existing) { + return existing; + } + + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace || !existsSync(workspace.worktreePath)) { + return { error: "Workspace worktree not found" }; + } + + const shell = resolveShell(); + const cwd = workspace.worktreePath; + + let pty: IPty; + try { + pty = spawn(shell, [], { + name: "xterm-256color", + cwd, + cols: 120, + rows: 32, + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + HOME: process.env.HOME || homedir(), + PWD: cwd, + }, + }); + } catch (error) { + return { + error: + error instanceof Error ? error.message : "Failed to start terminal", + }; + } + + db.insert(terminalSessions) + .values({ + id: terminalId, + workspaceId, + cwd, + shell, + launchMode, + command: command ?? null, + status: "active", + }) + .onConflictDoUpdate({ + target: terminalSessions.id, + set: { status: "active", endedAt: null }, + }) + .run(); + + const session: TerminalSession = { + terminalId, + pty, + socket: null, + buffer: [], + bufferBytes: 0, + exited: false, + exitCode: 0, + exitSignal: 0, + }; + sessions.set(terminalId, session); + + pty.onData((data) => { + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { type: "data", data }); + } else { + bufferOutput(session, data); + } + }); + + pty.onExit(({ exitCode, signal }) => { + session.exited = true; + session.exitCode = exitCode ?? 0; + session.exitSignal = signal ?? 0; + + db.update(terminalSessions) + .set({ status: "exited", endedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); + + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); + } + }); + + return session; } export function registerWorkspaceTerminalRoute({ @@ -99,126 +215,84 @@ export function registerWorkspaceTerminalRoute({ db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { + // Explicit terminal session creation endpoint + app.post("/terminal/sessions", async (c) => { + const body = await c.req.json<{ + terminalId: string; + workspaceId: string; + launchMode?: string; + command?: string; + }>(); + + if (!body.terminalId || !body.workspaceId) { + return c.json({ error: "Missing terminalId or workspaceId" }, 400); + } + + const result = createTerminalSessionInternal({ + terminalId: body.terminalId, + workspaceId: body.workspaceId, + db, + launchMode: body.launchMode, + command: body.command, + }); + + if ("error" in result) { + return c.json({ error: result.error }, 500); + } + + return c.json({ terminalId: result.terminalId, status: "active" }); + }); + + // WebSocket attach endpoint — session must already exist (created via POST above) app.get( - "/terminal/:paneId", + "/terminal/:terminalId", upgradeWebSocket((c) => { - const paneId = c.req.param("paneId"); - const workspaceId = c.req.query("workspaceId") ?? null; + const terminalId = c.req.param("terminalId"); return { onOpen: (_event, ws) => { - if (!paneId) { + if (!terminalId) { sendMessage(ws, { type: "error", - message: "Missing paneId", + message: "Missing terminalId", }); - ws.close(1011, "Missing paneId"); - return; - } - - 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) { - sendMessage(ws, { - type: "exit", - exitCode: existing.exitCode, - signal: existing.exitSignal, - }); - } + ws.close(1011, "Missing terminalId"); return; } - if (!workspaceId) { + const existing = sessions.get(terminalId); + if (!existing) { sendMessage(ws, { type: "error", - message: "Missing workspaceId for new terminal session", + message: + "No session found for terminalId — create via POST /terminal/sessions first", }); - ws.close(1011, "Missing workspaceId"); + ws.close(1011, "No session found"); 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", - }); - ws.close(1011, "Workspace worktree not found"); - return; + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); } + existing.socket = ws; - let pty: IPty; - try { - pty = spawn(resolveShell(), [], { - name: "xterm-256color", - cwd: workspace.worktreePath, - cols: 120, - rows: 32, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - HOME: process.env.HOME || homedir(), - PWD: workspace.worktreePath, - }, - }); - } catch (error) { + db.update(terminalSessions) + .set({ lastAttachedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); + + replayBuffer(existing, ws); + if (existing.exited) { sendMessage(ws, { - type: "error", - message: - error instanceof Error - ? error.message - : "Failed to start terminal", + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, }); - ws.close(1011, "Failed to start terminal"); - return; } - - 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?.readyState === 1) { - sendMessage(session.socket, { type: "data", data }); - } else { - bufferOutput(session, data); - } - }); - - pty.onExit(({ exitCode, signal }) => { - session.exited = true; - session.exitCode = exitCode ?? 0; - session.exitSignal = signal ?? 0; - - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { - type: "exit", - exitCode: session.exitCode, - signal: session.exitSignal, - }); - } - }); }, onMessage: (event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (!session || session.socket !== ws) return; let message: TerminalClientMessage; @@ -235,7 +309,7 @@ export function registerWorkspaceTerminalRoute({ } if (message.type === "dispose") { - disposeSession(paneId ?? ""); + disposeSession(terminalId ?? "", db); return; } @@ -254,14 +328,14 @@ export function registerWorkspaceTerminalRoute({ }, onClose: (_event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (session?.socket === ws) { session.socket = null; } }, onError: (_event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (session?.socket === ws) { session.socket = null; } From aa0dc6e148bedc1fc13a0f8dfc959628718be51c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 21:14:55 -0700 Subject: [PATCH 2/9] Implement feedback --- .../components/TerminalPane/TerminalPane.tsx | 115 +++++++++++++----- .../useGlobalTerminalLifecycle.ts | 43 ++++--- .../host-service/src/terminal/terminal.ts | 5 +- 3 files changed, 111 insertions(+), 52 deletions(-) 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 b7dd3452b4a..927e161808f 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,5 +1,11 @@ import "@xterm/xterm/css/xterm.css"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; import { type ConnectionState, terminalRuntimeRegistry, @@ -14,6 +20,9 @@ interface TerminalPaneProps { workspaceId: string; } +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 500; + function subscribeToState(terminalId: string) { return (callback: () => void) => terminalRuntimeRegistry.onStateChange(terminalId, callback); @@ -23,11 +32,31 @@ function getConnectionState(terminalId: string): ConnectionState { return terminalRuntimeRegistry.getConnectionState(terminalId); } +type SessionState = "creating" | "ready" | "error"; + +async function createSession( + hostUrl: string, + terminalId: string, + workspaceId: string, + signal: AbortSignal, +): Promise { + const res = await fetch(new URL("/terminal/sessions", hostUrl).href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId, workspaceId }), + signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`); + } +} + export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); const hostUrl = useWorkspaceHostUrl(); - const [sessionReady, setSessionReady] = useState(false); - const createAttemptedRef = useRef(false); + const [sessionState, setSessionState] = useState("creating"); + const [errorMessage, setErrorMessage] = useState(null); const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, @@ -38,35 +67,45 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { () => getConnectionState(terminalId), ); + const attemptCreate = useCallback( + (signal: AbortSignal) => { + setSessionState("creating"); + setErrorMessage(null); + + let attempt = 0; + const tryOnce = () => { + if (signal.aborted) return; + createSession(hostUrl, terminalId, workspaceId, signal) + .then(() => { + if (!signal.aborted) setSessionState("ready"); + }) + .catch((err: Error) => { + if (signal.aborted) return; + attempt++; + if (attempt < MAX_RETRIES) { + const delay = BASE_DELAY_MS * 2 ** (attempt - 1); + setTimeout(tryOnce, delay); + } else { + setErrorMessage(err.message); + setSessionState("error"); + } + }); + }; + tryOnce(); + }, + [hostUrl, terminalId, workspaceId], + ); + // Create the terminal session in host-service before attaching via websocket useEffect(() => { - if (createAttemptedRef.current) return; - createAttemptedRef.current = true; - - fetch(new URL("/terminal/sessions", hostUrl).href, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - terminalId, - workspaceId, - }), - }) - .then((res) => { - if (!res.ok) { - return res.json().then((body) => { - console.error("[TerminalPane] session create failed:", body); - }); - } - setSessionReady(true); - }) - .catch((err) => { - console.error("[TerminalPane] session create error:", err); - }); - }, [terminalId, workspaceId, hostUrl]); + const controller = new AbortController(); + attemptCreate(controller.signal); + return () => controller.abort(); + }, [attemptCreate]); // Attach to the terminal runtime only after the session has been created useEffect(() => { - if (!sessionReady) return; + if (sessionState !== "ready") return; const container = containerRef.current; if (!container) return; @@ -75,7 +114,11 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { return () => { terminalRuntimeRegistry.detach(terminalId); }; - }, [terminalId, websocketUrl, sessionReady]); + }, [terminalId, websocketUrl, sessionState]); + + const handleRetry = useCallback(() => { + attemptCreate(new AbortController().signal); + }, [attemptCreate]); return (
@@ -83,7 +126,21 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { ref={containerRef} className="min-h-0 flex-1 overflow-hidden bg-[#14100f]" /> - {connectionState === "closed" && ( + {sessionState === "error" && ( +
+ + Failed to create session{errorMessage ? `: ${errorMessage}` : ""} + + +
+ )} + {sessionState === "ready" && connectionState === "closed" && (
Disconnected
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 0c09d4cccae..6589893cec6 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 @@ -6,9 +6,9 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect /** * Cross-workspace moves temporarily remove a terminalId then re-add it. - * Wait before detaching the renderer runtime. + * Wait before disposing the renderer runtime. */ -const DETACH_DELAY_MS = 500; +const DISPOSE_DELAY_MS = 500; interface TerminalPaneData { terminalId: string; @@ -36,16 +36,16 @@ function extractTerminalIds(rows: { paneLayout: unknown }[]): Set { /** * Manages renderer-side terminal runtime lifecycle. * - * When a terminal pane is removed from workspace state, the renderer runtime - * (xterm + DOM wrapper) is detached but NOT disposed. The terminal session - * in host-service stays alive independently — pane removal does not kill - * the terminal. Only an explicit dispose action (e.g. user kills terminal) - * should call terminalRuntimeRegistry.dispose(). + * terminalId is the session key (independent of paneId). When no pane + * references a given terminalId, the renderer runtime AND the host-service + * session are disposed. The identity split means terminals *could* outlive + * panes (e.g. for a future "reattach" UI), but the default policy for this + * cut is dispose-on-unreferenced to avoid leaking hidden sessions. */ export function useGlobalTerminalLifecycle() { const collections = useCollections(); const prevTerminalIdsRef = useRef>(new Set()); - const pendingDetaches = useRef>>( + const pendingDisposals = useRef>>( new Map(), ); @@ -61,22 +61,22 @@ export function useGlobalTerminalLifecycle() { const currentTerminalIds = extractTerminalIds(allWorkspaceRows); const prevTerminalIds = prevTerminalIdsRef.current; - // Cancel pending detach for terminals that reappeared (cross-workspace move) + // Cancel pending dispose for terminals that reappeared (cross-workspace move) for (const terminalId of currentTerminalIds) { - const timer = pendingDetaches.current.get(terminalId); + const timer = pendingDisposals.current.get(terminalId); if (timer) { clearTimeout(timer); - pendingDetaches.current.delete(terminalId); + pendingDisposals.current.delete(terminalId); } } - // Schedule detach (not dispose) for terminals whose pane was removed + // Schedule dispose for terminals whose last pane reference was removed for (const terminalId of prevTerminalIds) { if (currentTerminalIds.has(terminalId)) continue; - if (pendingDetaches.current.has(terminalId)) continue; + if (pendingDisposals.current.has(terminalId)) continue; const timer = setTimeout(() => { - pendingDetaches.current.delete(terminalId); + pendingDisposals.current.delete(terminalId); const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), @@ -84,14 +84,13 @@ export function useGlobalTerminalLifecycle() { const freshIds = extractTerminalIds(freshRows); if (!freshIds.has(terminalId)) { - // Detach renderer runtime only — terminal session stays alive - // in host-service. The xterm instance and DOM wrapper are kept - // so a future pane can reattach without losing scrollback. - terminalRuntimeRegistry.detach(terminalId); + // Dispose renderer runtime (xterm + transport) and send dispose + // to host-service which kills the PTY and marks the DB row. + terminalRuntimeRegistry.dispose(terminalId); } - }, DETACH_DELAY_MS); + }, DISPOSE_DELAY_MS); - pendingDetaches.current.set(terminalId, timer); + pendingDisposals.current.set(terminalId, timer); } prevTerminalIdsRef.current = currentTerminalIds; @@ -99,10 +98,10 @@ export function useGlobalTerminalLifecycle() { useEffect(() => { return () => { - for (const timer of pendingDetaches.current.values()) { + for (const timer of pendingDisposals.current.values()) { clearTimeout(timer); } - pendingDetaches.current.clear(); + pendingDisposals.current.clear(); }; }, []); } diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 6b8d0f31c50..cebd137c98f 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -130,9 +130,12 @@ function createTerminalSessionInternal({ const shell = resolveShell(); const cwd = workspace.worktreePath; + // When launchMode is "command", run the command via shell -c + const spawnArgs = launchMode === "command" && command ? ["-c", command] : []; + let pty: IPty; try { - pty = spawn(shell, [], { + pty = spawn(shell, spawnArgs, { name: "xterm-256color", cwd, cols: 120, From 5f09fc9cf21c8f102cbb85ae1f7244111ee6c373 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Thu, 2 Apr 2026 23:45:53 -0700 Subject: [PATCH 3/9] ws authorize --- .../components/TerminalPane/TerminalPane.tsx | 113 +++++++++++------- 1 file changed, 68 insertions(+), 45 deletions(-) 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 927e161808f..2d432869e7c 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,4 +1,5 @@ import "@xterm/xterm/css/xterm.css"; +import { useWorkspaceClient } from "@superset/workspace-client"; import { useCallback, useEffect, @@ -10,10 +11,7 @@ import { type ConnectionState, terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; -import { - useWorkspaceHostUrl, - useWorkspaceWsUrl, -} from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; interface TerminalPaneProps { terminalId: string; @@ -34,13 +32,18 @@ function getConnectionState(terminalId: string): ConnectionState { type SessionState = "creating" | "ready" | "error"; -async function createSession( +async function postCreateSession( hostUrl: string, terminalId: string, workspaceId: string, signal: AbortSignal, + token: string | null, ): Promise { - const res = await fetch(new URL("/terminal/sessions", hostUrl).href, { + const url = new URL("/terminal/sessions", hostUrl); + if (token) { + url.searchParams.set("token", token); + } + const res = await fetch(url.href, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ terminalId, workspaceId }), @@ -54,10 +57,15 @@ async function createSession( export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { const containerRef = useRef(null); - const hostUrl = useWorkspaceHostUrl(); + const { hostUrl, getWsToken } = useWorkspaceClient(); const [sessionState, setSessionState] = useState("creating"); const [errorMessage, setErrorMessage] = useState(null); + // Single abort controller for all in-flight create/retry operations. + // Starting a new operation aborts the previous one; unmount aborts whatever + // is in flight. + const controllerRef = useRef(new AbortController()); + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, }); @@ -67,41 +75,43 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { () => getConnectionState(terminalId), ); - const attemptCreate = useCallback( - (signal: AbortSignal) => { - setSessionState("creating"); - setErrorMessage(null); - - let attempt = 0; - const tryOnce = () => { - if (signal.aborted) return; - createSession(hostUrl, terminalId, workspaceId, signal) - .then(() => { - if (!signal.aborted) setSessionState("ready"); - }) - .catch((err: Error) => { - if (signal.aborted) return; - attempt++; - if (attempt < MAX_RETRIES) { - const delay = BASE_DELAY_MS * 2 ** (attempt - 1); - setTimeout(tryOnce, delay); - } else { - setErrorMessage(err.message); - setSessionState("error"); - } - }); - }; - tryOnce(); - }, - [hostUrl, terminalId, workspaceId], - ); + const startCreateSession = useCallback(() => { + // Abort any previous in-flight operation + controllerRef.current.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + const { signal } = controller; + + setSessionState("creating"); + setErrorMessage(null); + + let attempt = 0; + const tryOnce = () => { + if (signal.aborted) return; + postCreateSession(hostUrl, terminalId, workspaceId, signal, getWsToken()) + .then(() => { + if (!signal.aborted) setSessionState("ready"); + }) + .catch((err: Error) => { + if (signal.aborted) return; + attempt++; + if (attempt < MAX_RETRIES) { + const delay = BASE_DELAY_MS * 2 ** (attempt - 1); + setTimeout(tryOnce, delay); + } else { + setErrorMessage(err.message); + setSessionState("error"); + } + }); + }; + tryOnce(); + }, [hostUrl, terminalId, workspaceId, getWsToken]); - // Create the terminal session in host-service before attaching via websocket + // Create the terminal session in host-service on mount useEffect(() => { - const controller = new AbortController(); - attemptCreate(controller.signal); - return () => controller.abort(); - }, [attemptCreate]); + startCreateSession(); + return () => controllerRef.current.abort(); + }, [startCreateSession]); // Attach to the terminal runtime only after the session has been created useEffect(() => { @@ -116,9 +126,22 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { }; }, [terminalId, websocketUrl, sessionState]); - const handleRetry = useCallback(() => { - attemptCreate(new AbortController().signal); - }, [attemptCreate]); + // Auto-reconnect: when the websocket closes while the session was ready, + // cycle back through create → attach. This handles host-service restarts + // (in-memory sessions lost) and transient socket drops. + const prevConnectionStateRef = useRef(connectionState); + useEffect(() => { + const prev = prevConnectionStateRef.current; + prevConnectionStateRef.current = connectionState; + + if ( + connectionState === "closed" && + prev !== "closed" && + sessionState === "ready" + ) { + startCreateSession(); + } + }, [connectionState, sessionState, startCreateSession]); return (
@@ -134,7 +157,7 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { @@ -142,7 +165,7 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { )} {sessionState === "ready" && connectionState === "closed" && (
- Disconnected + Reconnecting…
)}
From 380694df7c0574c29bca4126970316ef37b0d85f Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 09:52:47 -0700 Subject: [PATCH 4/9] Refactor state --- .../components/TerminalPane/TerminalPane.tsx | 148 ++---------------- .../TerminalPane/useTerminalSession.ts | 147 +++++++++++++++++ .../hooks/usePaneRegistry/usePaneRegistry.tsx | 13 +- .../useV2WorkspacePaneLayout.ts | 62 +------- 4 files changed, 166 insertions(+), 204 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts 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 2d432869e7c..dbefce2ac5f 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,147 +1,17 @@ +import type { RendererContext } from "@superset/panes"; import "@xterm/xterm/css/xterm.css"; -import { useWorkspaceClient } from "@superset/workspace-client"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - type ConnectionState, - terminalRuntimeRegistry, -} from "renderer/lib/terminal/terminal-runtime-registry"; -import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import type { PaneViewerData, TerminalPaneData } from "../../../../types"; +import { useTerminalSession } from "./useTerminalSession"; interface TerminalPaneProps { - terminalId: string; + ctx: RendererContext; workspaceId: string; } -const MAX_RETRIES = 3; -const BASE_DELAY_MS = 500; - -function subscribeToState(terminalId: string) { - return (callback: () => void) => - terminalRuntimeRegistry.onStateChange(terminalId, callback); -} - -function getConnectionState(terminalId: string): ConnectionState { - return terminalRuntimeRegistry.getConnectionState(terminalId); -} - -type SessionState = "creating" | "ready" | "error"; - -async function postCreateSession( - hostUrl: string, - terminalId: string, - workspaceId: string, - signal: AbortSignal, - token: string | null, -): Promise { - const url = new URL("/terminal/sessions", hostUrl); - if (token) { - url.searchParams.set("token", token); - } - const res = await fetch(url.href, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ terminalId, workspaceId }), - signal, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`); - } -} - -export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { - const containerRef = useRef(null); - const { hostUrl, getWsToken } = useWorkspaceClient(); - const [sessionState, setSessionState] = useState("creating"); - const [errorMessage, setErrorMessage] = useState(null); - - // Single abort controller for all in-flight create/retry operations. - // Starting a new operation aborts the previous one; unmount aborts whatever - // is in flight. - const controllerRef = useRef(new AbortController()); - - const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { - workspaceId, - }); - - const connectionState = useSyncExternalStore( - subscribeToState(terminalId), - () => getConnectionState(terminalId), - ); - - const startCreateSession = useCallback(() => { - // Abort any previous in-flight operation - controllerRef.current.abort(); - const controller = new AbortController(); - controllerRef.current = controller; - const { signal } = controller; - - setSessionState("creating"); - setErrorMessage(null); - - let attempt = 0; - const tryOnce = () => { - if (signal.aborted) return; - postCreateSession(hostUrl, terminalId, workspaceId, signal, getWsToken()) - .then(() => { - if (!signal.aborted) setSessionState("ready"); - }) - .catch((err: Error) => { - if (signal.aborted) return; - attempt++; - if (attempt < MAX_RETRIES) { - const delay = BASE_DELAY_MS * 2 ** (attempt - 1); - setTimeout(tryOnce, delay); - } else { - setErrorMessage(err.message); - setSessionState("error"); - } - }); - }; - tryOnce(); - }, [hostUrl, terminalId, workspaceId, getWsToken]); - - // Create the terminal session in host-service on mount - useEffect(() => { - startCreateSession(); - return () => controllerRef.current.abort(); - }, [startCreateSession]); - - // Attach to the terminal runtime only after the session has been created - useEffect(() => { - if (sessionState !== "ready") return; - const container = containerRef.current; - if (!container) return; - - terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); - - return () => { - terminalRuntimeRegistry.detach(terminalId); - }; - }, [terminalId, websocketUrl, sessionState]); - - // Auto-reconnect: when the websocket closes while the session was ready, - // cycle back through create → attach. This handles host-service restarts - // (in-memory sessions lost) and transient socket drops. - const prevConnectionStateRef = useRef(connectionState); - useEffect(() => { - const prev = prevConnectionStateRef.current; - prevConnectionStateRef.current = connectionState; - - if ( - connectionState === "closed" && - prev !== "closed" && - sessionState === "ready" - ) { - startCreateSession(); - } - }, [connectionState, sessionState, startCreateSession]); +export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { + const { terminalId } = ctx.pane.data as TerminalPaneData; + const { sessionState, connectionState, errorMessage, containerRef, retry } = + useTerminalSession(terminalId, workspaceId); return (
@@ -157,7 +27,7 @@ export function TerminalPane({ terminalId, workspaceId }: TerminalPaneProps) { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts new file mode 100644 index 00000000000..f05d0ee04d8 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts @@ -0,0 +1,147 @@ +import { useWorkspaceClient } from "@superset/workspace-client"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; +import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; + +const MAX_ATTEMPTS = 3; +const BASE_DELAY_MS = 500; + +type SessionState = "creating" | "ready" | "error"; + +async function postCreateSession( + hostUrl: string, + terminalId: string, + workspaceId: string, + signal: AbortSignal, + token: string | null, +): Promise { + const url = new URL("/terminal/sessions", hostUrl); + if (token) { + url.searchParams.set("token", token); + } + const res = await fetch(url.href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ terminalId, workspaceId }), + signal, + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`); + } +} + +export interface UseTerminalSessionResult { + sessionState: SessionState; + connectionState: ConnectionState; + errorMessage: string | null; + containerRef: React.RefObject; + retry: () => void; +} + +export function useTerminalSession( + terminalId: string, + workspaceId: string, +): UseTerminalSessionResult { + const containerRef = useRef(null); + const { hostUrl, getWsToken } = useWorkspaceClient(); + const [sessionState, setSessionState] = useState("creating"); + const [errorMessage, setErrorMessage] = useState(null); + + const controllerRef = useRef(new AbortController()); + + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { + workspaceId, + }); + + const connectionState = useSyncExternalStore( + useCallback( + (callback: () => void) => + terminalRuntimeRegistry.onStateChange(terminalId, callback), + [terminalId], + ), + () => terminalRuntimeRegistry.getConnectionState(terminalId), + ); + + const startCreateSession = useCallback(() => { + controllerRef.current.abort(); + const controller = new AbortController(); + controllerRef.current = controller; + const { signal } = controller; + + setSessionState("creating"); + setErrorMessage(null); + + let attempt = 0; + const tryOnce = () => { + if (signal.aborted) return; + postCreateSession(hostUrl, terminalId, workspaceId, signal, getWsToken()) + .then(() => { + if (!signal.aborted) setSessionState("ready"); + }) + .catch((err: Error) => { + if (signal.aborted) return; + attempt++; + if (attempt < MAX_ATTEMPTS) { + const delay = BASE_DELAY_MS * 2 ** (attempt - 1); + setTimeout(tryOnce, delay); + } else { + setErrorMessage(err.message); + setSessionState("error"); + } + }); + }; + tryOnce(); + }, [hostUrl, terminalId, workspaceId, getWsToken]); + + // Create session on mount + useEffect(() => { + startCreateSession(); + return () => controllerRef.current.abort(); + }, [startCreateSession]); + + // Attach runtime once session is ready + useEffect(() => { + if (sessionState !== "ready") return; + const container = containerRef.current; + if (!container) return; + + terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); + + return () => { + terminalRuntimeRegistry.detach(terminalId); + }; + }, [terminalId, websocketUrl, sessionState]); + + // Auto-reconnect on websocket close + const prevConnectionStateRef = useRef(connectionState); + useEffect(() => { + const prev = prevConnectionStateRef.current; + prevConnectionStateRef.current = connectionState; + + if ( + connectionState === "closed" && + prev !== "closed" && + sessionState === "ready" + ) { + startCreateSession(); + } + }, [connectionState, sessionState, startCreateSession]); + + return { + sessionState, + connectionState, + errorMessage, + containerRef, + retry: startCreateSession, + }; +} 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 07b64b2ba72..25924bb92a7 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 @@ -9,7 +9,6 @@ import type { DevtoolsPaneData, FilePaneData, PaneViewerData, - TerminalPaneData, } from "../../types"; import { ChatPane } from "./components/ChatPane"; import { FilePane } from "./components/FilePane"; @@ -83,15 +82,9 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", - renderPane: (ctx: RendererContext) => { - const data = ctx.pane.data as TerminalPaneData; - return ( - - ); - }, + renderPane: (ctx: RendererContext) => ( + + ), }, browser: { getIcon: () => , diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts index 9b2b101d39c..088c332ccda 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2WorkspacePaneLayout/useV2WorkspacePaneLayout.ts @@ -1,14 +1,10 @@ -import { - createWorkspaceStore, - type Pane, - type WorkspaceState, -} from "@superset/panes"; +import { createWorkspaceStore, type WorkspaceState } from "@superset/panes"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useMemo, useRef, useState } from "react"; import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -import type { PaneViewerData, TerminalPaneData } from "../../types"; +import type { PaneViewerData } from "../../types"; const EMPTY_STATE: WorkspaceState = { version: 1, @@ -16,39 +12,6 @@ const EMPTY_STATE: WorkspaceState = { activeTabId: null, }; -interface LegacyTerminalPaneData { - sessionKey?: string; - cwd?: string; - launchMode?: string; - command?: string; - terminalId?: string; -} - -/** - * Migrate legacy terminal pane data to the new {terminalId} shape. - * Old panes had {sessionKey, cwd, launchMode, command?} — convert them - * in-place so the renderer always sees {terminalId}. - * Returns true if any pane was migrated (caller should persist). - */ -function migrateTerminalPaneData( - state: WorkspaceState, -): boolean { - let migrated = false; - for (const tab of state.tabs) { - for (const pane of Object.values(tab.panes)) { - if (pane.kind !== "terminal") continue; - const data = pane.data as unknown as LegacyTerminalPaneData; - if (data.terminalId) continue; - // Legacy pane — assign a new terminalId - (pane as Pane).data = { - terminalId: crypto.randomUUID(), - } as TerminalPaneData as PaneViewerData; - migrated = true; - } - } - return migrated; -} - function getSnapshot(state: WorkspaceState): string { return JSON.stringify(state); } @@ -81,24 +44,13 @@ export function useV2WorkspacePaneLayout({ [collections, workspaceId], ); const localWorkspaceState = localWorkspaceRows[0] ?? null; - const persistedPaneLayout = useMemo(() => { - const layout = + const persistedPaneLayout = useMemo( + () => (localWorkspaceState?.paneLayout as | WorkspaceState - | undefined) ?? EMPTY_STATE; - - // Migrate legacy terminal panes ({sessionKey, cwd, …} → {terminalId}) - if (layout !== EMPTY_STATE && migrateTerminalPaneData(layout)) { - // Persist the migrated layout back so the migration only runs once - if (collections.v2WorkspaceLocalState.get(workspaceId)) { - collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { - draft.paneLayout = layout; - }); - } - } - - return layout; - }, [localWorkspaceState, collections, workspaceId]); + | undefined) ?? EMPTY_STATE, + [localWorkspaceState], + ); useEffect(() => { ensureWorkspaceInSidebar(workspaceId, projectId); From 57dd9642fe79228e89124ae6dce0a3b28aebdf39 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 09:58:12 -0700 Subject: [PATCH 5/9] refactor: remove session creation state from TerminalPane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore auto-create-on-websocket-connect in host-service instead of requiring a separate POST before attach. This removes all the extra renderer state (sessionState, retry, reconnect) that was only needed for the two-step create/attach flow. TerminalPane is back to its original shape — just terminalId instead of paneId. The POST /terminal/sessions endpoint stays as a dormant API for future use. --- .../components/TerminalPane/TerminalPane.tsx | 57 ++++--- .../TerminalPane/useTerminalSession.ts | 147 ------------------ .../host-service/src/terminal/terminal.ts | 62 +++++--- 3 files changed, 78 insertions(+), 188 deletions(-) delete mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts 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 dbefce2ac5f..ce9d7e95ac4 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,17 +1,50 @@ import type { RendererContext } from "@superset/panes"; import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef, useSyncExternalStore } from "react"; +import { + type ConnectionState, + terminalRuntimeRegistry, +} from "renderer/lib/terminal/terminal-runtime-registry"; +import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; import type { PaneViewerData, TerminalPaneData } from "../../../../types"; -import { useTerminalSession } from "./useTerminalSession"; interface TerminalPaneProps { ctx: RendererContext; workspaceId: string; } +function subscribeToState(terminalId: string) { + return (callback: () => void) => + terminalRuntimeRegistry.onStateChange(terminalId, callback); +} + +function getConnectionState(terminalId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(terminalId); +} + export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { const { terminalId } = ctx.pane.data as TerminalPaneData; - const { sessionState, connectionState, errorMessage, containerRef, retry } = - useTerminalSession(terminalId, workspaceId); + const containerRef = useRef(null); + + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { + workspaceId, + }); + + const connectionState = useSyncExternalStore( + subscribeToState(terminalId), + () => getConnectionState(terminalId), + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); + + return () => { + terminalRuntimeRegistry.detach(terminalId); + }; + }, [terminalId, websocketUrl]); return (
@@ -19,23 +52,9 @@ export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { ref={containerRef} className="min-h-0 flex-1 overflow-hidden bg-[#14100f]" /> - {sessionState === "error" && ( -
- - Failed to create session{errorMessage ? `: ${errorMessage}` : ""} - - -
- )} - {sessionState === "ready" && connectionState === "closed" && ( + {connectionState === "closed" && (
- Reconnecting… + Disconnected
)}
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts deleted file mode 100644 index f05d0ee04d8..00000000000 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/useTerminalSession.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { useWorkspaceClient } from "@superset/workspace-client"; -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, -} from "react"; -import { - type ConnectionState, - terminalRuntimeRegistry, -} from "renderer/lib/terminal/terminal-runtime-registry"; -import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; - -const MAX_ATTEMPTS = 3; -const BASE_DELAY_MS = 500; - -type SessionState = "creating" | "ready" | "error"; - -async function postCreateSession( - hostUrl: string, - terminalId: string, - workspaceId: string, - signal: AbortSignal, - token: string | null, -): Promise { - const url = new URL("/terminal/sessions", hostUrl); - if (token) { - url.searchParams.set("token", token); - } - const res = await fetch(url.href, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ terminalId, workspaceId }), - signal, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`); - } -} - -export interface UseTerminalSessionResult { - sessionState: SessionState; - connectionState: ConnectionState; - errorMessage: string | null; - containerRef: React.RefObject; - retry: () => void; -} - -export function useTerminalSession( - terminalId: string, - workspaceId: string, -): UseTerminalSessionResult { - const containerRef = useRef(null); - const { hostUrl, getWsToken } = useWorkspaceClient(); - const [sessionState, setSessionState] = useState("creating"); - const [errorMessage, setErrorMessage] = useState(null); - - const controllerRef = useRef(new AbortController()); - - const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { - workspaceId, - }); - - const connectionState = useSyncExternalStore( - useCallback( - (callback: () => void) => - terminalRuntimeRegistry.onStateChange(terminalId, callback), - [terminalId], - ), - () => terminalRuntimeRegistry.getConnectionState(terminalId), - ); - - const startCreateSession = useCallback(() => { - controllerRef.current.abort(); - const controller = new AbortController(); - controllerRef.current = controller; - const { signal } = controller; - - setSessionState("creating"); - setErrorMessage(null); - - let attempt = 0; - const tryOnce = () => { - if (signal.aborted) return; - postCreateSession(hostUrl, terminalId, workspaceId, signal, getWsToken()) - .then(() => { - if (!signal.aborted) setSessionState("ready"); - }) - .catch((err: Error) => { - if (signal.aborted) return; - attempt++; - if (attempt < MAX_ATTEMPTS) { - const delay = BASE_DELAY_MS * 2 ** (attempt - 1); - setTimeout(tryOnce, delay); - } else { - setErrorMessage(err.message); - setSessionState("error"); - } - }); - }; - tryOnce(); - }, [hostUrl, terminalId, workspaceId, getWsToken]); - - // Create session on mount - useEffect(() => { - startCreateSession(); - return () => controllerRef.current.abort(); - }, [startCreateSession]); - - // Attach runtime once session is ready - useEffect(() => { - if (sessionState !== "ready") return; - const container = containerRef.current; - if (!container) return; - - terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); - - return () => { - terminalRuntimeRegistry.detach(terminalId); - }; - }, [terminalId, websocketUrl, sessionState]); - - // Auto-reconnect on websocket close - const prevConnectionStateRef = useRef(connectionState); - useEffect(() => { - const prev = prevConnectionStateRef.current; - prevConnectionStateRef.current = connectionState; - - if ( - connectionState === "closed" && - prev !== "closed" && - sessionState === "ready" - ) { - startCreateSession(); - } - }, [connectionState, sessionState, startCreateSession]); - - return { - sessionState, - connectionState, - errorMessage, - containerRef, - retry: startCreateSession, - }; -} diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index cebd137c98f..889c89781c7 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -246,52 +246,70 @@ export function registerWorkspaceTerminalRoute({ return c.json({ terminalId: result.terminalId, status: "active" }); }); - // WebSocket attach endpoint — session must already exist (created via POST above) + // WebSocket endpoint — auto-creates session on first connect, reattaches on reconnect app.get( "/terminal/:terminalId", upgradeWebSocket((c) => { - const terminalId = c.req.param("terminalId"); + const terminalId = c.req.param("terminalId") ?? ""; + const workspaceId = c.req.query("workspaceId") ?? null; return { onOpen: (_event, ws) => { if (!terminalId) { - sendMessage(ws, { - type: "error", - message: "Missing terminalId", - }); ws.close(1011, "Missing terminalId"); return; } const existing = sessions.get(terminalId); - if (!existing) { + if (existing) { + if (existing.socket && existing.socket !== ws) { + existing.socket.close(4000, "Displaced by new connection"); + } + existing.socket = ws; + + db.update(terminalSessions) + .set({ lastAttachedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); + + replayBuffer(existing, ws); + if (existing.exited) { + sendMessage(ws, { + type: "exit", + exitCode: existing.exitCode, + signal: existing.exitSignal, + }); + } + return; + } + + if (!workspaceId) { sendMessage(ws, { type: "error", - message: - "No session found for terminalId — create via POST /terminal/sessions first", + message: "Missing workspaceId for new terminal session", }); - ws.close(1011, "No session found"); + ws.close(1011, "Missing workspaceId"); return; } - if (existing.socket && existing.socket !== ws) { - existing.socket.close(4000, "Displaced by new connection"); + const result = createTerminalSessionInternal({ + terminalId, + workspaceId, + db, + }); + + if ("error" in result) { + sendMessage(ws, { type: "error", message: result.error }); + ws.close(1011, result.error); + return; } - existing.socket = ws; + + result.socket = ws; db.update(terminalSessions) .set({ lastAttachedAt: Date.now() }) .where(eq(terminalSessions.id, terminalId)) .run(); - - replayBuffer(existing, ws); - if (existing.exited) { - sendMessage(ws, { - type: "exit", - exitCode: existing.exitCode, - signal: existing.exitSignal, - }); - } }, onMessage: (event, ws) => { From 6c4570bb0c7317da4be3e9b6d1386377fbc23f02 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 10:01:30 -0700 Subject: [PATCH 6/9] style: replace relative paths with tsconfig aliases in TerminalPane --- .../components/TerminalPane/TerminalPane.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 ce9d7e95ac4..2799cbf15d1 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 @@ -5,8 +5,11 @@ import { type ConnectionState, terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; -import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; -import type { PaneViewerData, TerminalPaneData } from "../../../../types"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; interface TerminalPaneProps { ctx: RendererContext; From 427fb9637914fc8fcf6143c2317be23f1041446c Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 10:57:18 -0700 Subject: [PATCH 7/9] refactor: strip create metadata from terminal_sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep terminal_sessions minimal — only session lifecycle state: id, origin_workspace_id, status, created_at, last_attached_at, ended_at. Removed cwd, shell, launchMode, command — those are create request inputs, not session state. They can be persisted separately later if needed for revive/replay. --- .../drizzle/0002_add_terminal_sessions.sql | 10 ++-- .../drizzle/meta/0002_snapshot.json | 47 ++++--------------- .../host-service/drizzle/meta/_journal.json | 2 +- packages/host-service/src/db/schema.ts | 15 +++--- .../host-service/src/terminal/terminal.ts | 20 +------- 5 files changed, 22 insertions(+), 72 deletions(-) diff --git a/packages/host-service/drizzle/0002_add_terminal_sessions.sql b/packages/host-service/drizzle/0002_add_terminal_sessions.sql index 60954cfecfc..ddeea435345 100644 --- a/packages/host-service/drizzle/0002_add_terminal_sessions.sql +++ b/packages/host-service/drizzle/0002_add_terminal_sessions.sql @@ -1,16 +1,12 @@ CREATE TABLE `terminal_sessions` ( `id` text PRIMARY KEY NOT NULL, - `workspace_id` text, - `cwd` text NOT NULL, - `shell` text NOT NULL, - `launch_mode` text DEFAULT 'workspace-shell' NOT NULL, - `command` text, + `origin_workspace_id` text, `status` text DEFAULT 'active' NOT NULL, `created_at` integer NOT NULL, `last_attached_at` integer, `ended_at` integer, - FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE set null + FOREIGN KEY (`origin_workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE set null ); --> statement-breakpoint -CREATE INDEX `terminal_sessions_workspace_id_idx` ON `terminal_sessions` (`workspace_id`);--> statement-breakpoint +CREATE INDEX `terminal_sessions_origin_workspace_id_idx` ON `terminal_sessions` (`origin_workspace_id`);--> statement-breakpoint CREATE INDEX `terminal_sessions_status_idx` ON `terminal_sessions` (`status`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0002_snapshot.json b/packages/host-service/drizzle/meta/0002_snapshot.json index a70c618a15e..a68b6bac9a5 100644 --- a/packages/host-service/drizzle/meta/0002_snapshot.json +++ b/packages/host-service/drizzle/meta/0002_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "79567b7e-1bbb-4654-97e9-a54b29d33205", + "id": "a2434e05-3865-4783-9247-7bda589c8806", "prevId": "f5887d62-b5ad-4441-9648-d4456be25acb", "tables": { "projects": { @@ -276,37 +276,8 @@ "notNull": true, "autoincrement": false }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cwd": { - "name": "cwd", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "shell": { - "name": "shell", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "launch_mode": { - "name": "launch_mode", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'workspace-shell'" - }, - "command": { - "name": "command", + "origin_workspace_id": { + "name": "origin_workspace_id", "type": "text", "primaryKey": false, "notNull": false, @@ -343,10 +314,10 @@ } }, "indexes": { - "terminal_sessions_workspace_id_idx": { - "name": "terminal_sessions_workspace_id_idx", + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", "columns": [ - "workspace_id" + "origin_workspace_id" ], "isUnique": false }, @@ -359,12 +330,12 @@ } }, "foreignKeys": { - "terminal_sessions_workspace_id_workspaces_id_fk": { - "name": "terminal_sessions_workspace_id_workspaces_id_fk", + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", "tableFrom": "terminal_sessions", "tableTo": "workspaces", "columnsFrom": [ - "workspace_id" + "origin_workspace_id" ], "columnsTo": [ "id" diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index 6bb4b5f1a96..348757a3259 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -19,7 +19,7 @@ { "idx": 2, "version": "6", - "when": 1775186329285, + "when": 1775239013772, "tag": "0002_add_terminal_sessions", "breakpoints": true } diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index a66cf14138f..4ae289694fd 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -10,13 +10,10 @@ export const terminalSessions = sqliteTable( "terminal_sessions", { id: text().primaryKey(), - workspaceId: text("workspace_id").references(() => workspaces.id, { - onDelete: "set null", - }), - cwd: text().notNull(), - shell: text().notNull(), - launchMode: text("launch_mode").notNull().default("workspace-shell"), - command: text(), + originWorkspaceId: text("origin_workspace_id").references( + () => workspaces.id, + { onDelete: "set null" }, + ), status: text().notNull().default("active"), createdAt: integer("created_at") .notNull() @@ -25,7 +22,9 @@ export const terminalSessions = sqliteTable( endedAt: integer("ended_at"), }, (table) => [ - index("terminal_sessions_workspace_id_idx").on(table.workspaceId), + index("terminal_sessions_origin_workspace_id_idx").on( + table.originWorkspaceId, + ), index("terminal_sessions_status_idx").on(table.status), ], ); diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 889c89781c7..c4fd09a0df4 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -103,16 +103,12 @@ interface CreateTerminalSessionOptions { terminalId: string; workspaceId: string; db: HostDb; - launchMode?: string; - command?: string; } function createTerminalSessionInternal({ terminalId, workspaceId, db, - launchMode = "workspace-shell", - command, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); if (existing) { @@ -127,15 +123,11 @@ function createTerminalSessionInternal({ return { error: "Workspace worktree not found" }; } - const shell = resolveShell(); const cwd = workspace.worktreePath; - // When launchMode is "command", run the command via shell -c - const spawnArgs = launchMode === "command" && command ? ["-c", command] : []; - let pty: IPty; try { - pty = spawn(shell, spawnArgs, { + pty = spawn(resolveShell(), [], { name: "xterm-256color", cwd, cols: 120, @@ -158,11 +150,7 @@ function createTerminalSessionInternal({ db.insert(terminalSessions) .values({ id: terminalId, - workspaceId, - cwd, - shell, - launchMode, - command: command ?? null, + originWorkspaceId: workspaceId, status: "active", }) .onConflictDoUpdate({ @@ -223,8 +211,6 @@ export function registerWorkspaceTerminalRoute({ const body = await c.req.json<{ terminalId: string; workspaceId: string; - launchMode?: string; - command?: string; }>(); if (!body.terminalId || !body.workspaceId) { @@ -235,8 +221,6 @@ export function registerWorkspaceTerminalRoute({ terminalId: body.terminalId, workspaceId: body.workspaceId, db, - launchMode: body.launchMode, - command: body.command, }); if ("error" in result) { From 68570613a4fa6e6a74c639bf02a6535f8d8978d3 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 11:03:16 -0700 Subject: [PATCH 8/9] Fix lint --- .../_dashboard/v2-workspace/$workspaceId/page.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 05881b41682..26612fb087d 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -66,7 +66,6 @@ function V2WorkspacePage() { ); } @@ -74,11 +73,9 @@ function V2WorkspacePage() { function WorkspaceContent({ projectId, workspaceId, - workspaceName, }: { projectId: string; workspaceId: string; - workspaceName: string; }) { const navigate = useNavigate(); const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ @@ -360,7 +357,6 @@ function WorkspaceContent({ query={commandPalette.query} scope={commandPalette.scope} searchResults={commandPalette.searchResults} - workspaceName={workspaceName} /> ); From a245eff6d3a684f0d195bdce842aafde5dfd3001 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 3 Apr 2026 11:08:22 -0700 Subject: [PATCH 9/9] style: remove redundant comments --- .../lib/terminal/terminal-runtime-registry.ts | 9 --------- .../useGlobalTerminalLifecycle.ts | 18 +----------------- packages/host-service/src/terminal/terminal.ts | 2 -- 3 files changed, 1 insertion(+), 28 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 acd7d01d906..f28c71fb7f3 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -46,15 +46,6 @@ class TerminalRuntimeRegistryImpl { 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 terminalId is removed - * from persisted pane state. - */ detach(terminalId: string) { const entry = this.entries.get(terminalId); if (!entry) return; 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 6589893cec6..172eb65ac26 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 @@ -4,10 +4,7 @@ import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -/** - * Cross-workspace moves temporarily remove a terminalId then re-add it. - * Wait before disposing the renderer runtime. - */ +/** Grace period for cross-workspace pane moves before disposing. */ const DISPOSE_DELAY_MS = 500; interface TerminalPaneData { @@ -33,15 +30,6 @@ function extractTerminalIds(rows: { paneLayout: unknown }[]): Set { return ids; } -/** - * Manages renderer-side terminal runtime lifecycle. - * - * terminalId is the session key (independent of paneId). When no pane - * references a given terminalId, the renderer runtime AND the host-service - * session are disposed. The identity split means terminals *could* outlive - * panes (e.g. for a future "reattach" UI), but the default policy for this - * cut is dispose-on-unreferenced to avoid leaking hidden sessions. - */ export function useGlobalTerminalLifecycle() { const collections = useCollections(); const prevTerminalIdsRef = useRef>(new Set()); @@ -61,7 +49,6 @@ export function useGlobalTerminalLifecycle() { const currentTerminalIds = extractTerminalIds(allWorkspaceRows); const prevTerminalIds = prevTerminalIdsRef.current; - // Cancel pending dispose for terminals that reappeared (cross-workspace move) for (const terminalId of currentTerminalIds) { const timer = pendingDisposals.current.get(terminalId); if (timer) { @@ -70,7 +57,6 @@ export function useGlobalTerminalLifecycle() { } } - // Schedule dispose for terminals whose last pane reference was removed for (const terminalId of prevTerminalIds) { if (currentTerminalIds.has(terminalId)) continue; if (pendingDisposals.current.has(terminalId)) continue; @@ -84,8 +70,6 @@ export function useGlobalTerminalLifecycle() { const freshIds = extractTerminalIds(freshRows); if (!freshIds.has(terminalId)) { - // Dispose renderer runtime (xterm + transport) and send dispose - // to host-service which kills the PTY and marks the DB row. terminalRuntimeRegistry.dispose(terminalId); } }, DISPOSE_DELAY_MS); diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index c4fd09a0df4..bf97a5e5583 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -206,7 +206,6 @@ export function registerWorkspaceTerminalRoute({ db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { - // Explicit terminal session creation endpoint app.post("/terminal/sessions", async (c) => { const body = await c.req.json<{ terminalId: string; @@ -230,7 +229,6 @@ export function registerWorkspaceTerminalRoute({ return c.json({ terminalId: result.terminalId, status: "active" }); }); - // WebSocket endpoint — auto-creates session on first connect, reattaches on reconnect app.get( "/terminal/:terminalId", upgradeWebSocket((c) => {