From c8c390da55c0733778c775de0ff698615811bd87 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 12:27:25 -0700 Subject: [PATCH 1/8] fix v2 terminal lifecycle after sleep --- .../lib/terminal/terminal-runtime-registry.ts | 35 ++++- .../renderer/lib/terminal/terminal-runtime.ts | 16 ++- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 49 ++++++- .../useGlobalBrowserLifecycle.ts | 105 ++++++++++----- .../useGlobalTerminalLifecycle.ts | 127 ++++++++++++------ .../utils/paneLifecycleRows/index.ts | 8 ++ .../paneLifecycleRows.test.ts | 109 +++++++++++++++ .../paneLifecycleRows/paneLifecycleRows.ts | 93 +++++++++++++ .../useDashboardSidebarState.ts | 40 +++++- .../src/trpc/router/terminal/terminal.ts | 12 ++ 10 files changed, 500 insertions(+), 94 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index e10d2e1ab2d..07362001ebf 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -160,17 +160,40 @@ class TerminalRuntimeRegistryImpl { } } - dispose(terminalId: string) { + private disposeEntry( + terminalId: string, + entry: RegistryEntry, + options: { clearPersistedState?: boolean } = {}, + ) { + entry.linkManager?.dispose(); + disposeTransport(entry.transport); + if (entry.runtime) { + disposeRuntime(entry.runtime, options); + } + this.entries.delete(terminalId); + } + + /** + * Release the renderer-side terminal runtime only. This detaches the xterm + * view and closes the WebSocket, but it does not tell host-service to kill + * the underlying PTY. Use this for pane/sidebar lifecycle cleanup. + */ + release(terminalId: string) { const entry = this.entries.get(terminalId); if (!entry) return; + this.disposeEntry(terminalId, entry, { clearPersistedState: false }); + } - entry.linkManager?.dispose(); + /** + * Kill the host-service terminal session and remove all renderer-side state. + * This is destructive and should only be used from explicit kill actions. + */ + dispose(terminalId: string) { + const entry = this.entries.get(terminalId); + if (!entry) return; sendDispose(entry.transport); - disposeTransport(entry.transport); - if (entry.runtime) disposeRuntime(entry.runtime); - - this.entries.delete(terminalId); + this.disposeEntry(terminalId, entry); } getSelection(terminalId: string): string { diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 108784061b2..87de6298b98 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -360,13 +360,23 @@ export function updateRuntimeAppearance( } } -export function disposeRuntime(runtime: TerminalRuntime) { +export function disposeRuntime( + runtime: TerminalRuntime, + options: { clearPersistedState?: boolean } = {}, +) { + const clearPersistedState = options.clearPersistedState ?? true; + if (!clearPersistedState) { + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); + } runtime._disposeAddons?.(); runtime._disposeAddons = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); runtime.terminal.dispose(); - clearPersistedBuffer(runtime.terminalId); - clearPersistedDimensions(runtime.terminalId); + if (clearPersistedState) { + clearPersistedBuffer(runtime.terminalId); + clearPersistedDimensions(runtime.terminalId); + } } 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 4dd3195bc33..351e1bb13d6 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 @@ -4,8 +4,10 @@ import type { RendererContext, } from "@superset/panes"; import { alert } from "@superset/ui/atoms/Alert"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; +import { workspaceTrpc } from "@superset/workspace-client"; import { Circle, GitCompareArrows, @@ -22,6 +24,7 @@ import { LuClipboard, LuClipboardCopy, LuEraser, + LuPower, } from "react-icons/lu"; import { TbScan } from "react-icons/tb"; import { useHotkeyDisplay } from "renderer/hotkeys"; @@ -145,6 +148,16 @@ export function usePaneRegistry( ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation({ + onSuccess: () => { + toast.success("Terminal session killed"); + }, + onError: (error) => { + toast.error("Failed to kill terminal session", { + description: error.message, + }); + }, + }); return useMemo>( () => ({ @@ -306,10 +319,41 @@ export function usePaneRegistry( // Update close label const modifiedDefaults = defaults.map((d) => - d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, + d.key === "close-pane" ? { ...d, label: "Close Terminal Pane" } : d, ); - return [...terminalActions, ...modifiedDefaults]; + const killAction: ContextMenuActionConfig = { + key: "kill-terminal-session", + label: "Kill Terminal Session", + icon: , + variant: "destructive", + disabled: killTerminalSession.isPending, + onSelect: (ctx) => { + const { terminalId } = ctx.pane.data as TerminalPaneData; + alert({ + title: "Kill terminal session?", + description: + "This will terminate the underlying process. Closing the pane only detaches the view.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Kill Session", + variant: "destructive", + onClick: () => { + killTerminalSession.mutate({ terminalId }); + }, + }, + ], + }); + }, + }; + + return [ + ...terminalActions, + ...modifiedDefaults, + { key: "sep-terminal-kill", type: "separator" }, + killAction, + ]; }, }, browser: { @@ -409,6 +453,7 @@ export function usePaneRegistry( workspaceId, clearShortcut, scrollToBottomShortcut, + killTerminalSession, onOpenFile, onRevealPath, ], diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts index 92c2a17f489..2f5768fa2af 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalBrowserLifecycle/hooks/useGlobalBrowserLifecycle/useGlobalBrowserLifecycle.ts @@ -3,6 +3,12 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useRef } from "react"; import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; /** * Grace period for cross-workspace pane moves / renames before destroying. @@ -10,20 +16,21 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect */ const DESTROY_DELAY_MS = 500; -function extractBrowserPaneIds(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 pane of Object.values(tab.panes)) { - if (pane.kind === "browser") { - ids.add(pane.id); - } - } - } - } - return ids; +interface PendingBrowserDestruction { + workspaceId: string; + timer: ReturnType | null; +} + +function getBrowserPaneId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function extractBrowserLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getBrowserPaneId); } /** @@ -40,8 +47,8 @@ function extractBrowserPaneIds(rows: { paneLayout: unknown }[]): Set { */ export function useGlobalBrowserLifecycle() { const collections = useCollections(); - const prevBrowserIdsRef = useRef>(new Set()); - const pendingDestruction = useRef>>( + const prevBrowserLocationsRef = useRef>(new Map()); + const pendingDestruction = useRef>( new Map(), ); @@ -54,47 +61,79 @@ export function useGlobalBrowserLifecycle() { ); useEffect(() => { - const currentBrowserIds = extractBrowserPaneIds(allWorkspaceRows); - const prevBrowserIds = prevBrowserIdsRef.current; + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentBrowserLocations = extractBrowserLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevBrowserLocations = prevBrowserLocationsRef.current; // Cancel any pending destruction for ids that reappeared (e.g. pane // moved between workspaces, user undo, or the transient replaceState // churn we were fighting in the first place). - for (const browserId of currentBrowserIds) { - const timer = pendingDestruction.current.get(browserId); - if (timer) { - clearTimeout(timer); + for (const browserId of currentBrowserLocations.keys()) { + const pending = pendingDestruction.current.get(browserId); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingDestruction.current.delete(browserId); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids destroying webviews during sleep/wake while still + // cleaning up when the post-removal layout comes back. + for (const [browserId, pending] of pendingDestruction.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { pendingDestruction.current.delete(browserId); + browserRuntimeRegistry.destroy(browserId); } } - for (const browserId of prevBrowserIds) { - if (currentBrowserIds.has(browserId)) continue; + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevBrowserLocations, + currentLocations: currentBrowserLocations, + currentWorkspaceIds, + }); + + for (const { id: browserId, workspaceId } of removedLocations) { if (pendingDestruction.current.has(browserId)) continue; const timer = setTimeout(() => { - pendingDestruction.current.delete(browserId); - const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractBrowserPaneIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractBrowserLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); - if (!freshIds.has(browserId)) { + if (freshLocations.has(browserId)) { + pendingDestruction.current.delete(browserId); + return; + } + + if (freshWorkspaceIds.has(workspaceId)) { + pendingDestruction.current.delete(browserId); browserRuntimeRegistry.destroy(browserId); + return; + } + + const pending = pendingDestruction.current.get(browserId); + if (pending) { + pending.timer = null; } }, DESTROY_DELAY_MS); - pendingDestruction.current.set(browserId, timer); + pendingDestruction.current.set(browserId, { workspaceId, timer }); } - prevBrowserIdsRef.current = currentBrowserIds; + prevBrowserLocationsRef.current = currentBrowserLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDestruction.current.values()) { - clearTimeout(timer); + for (const pending of pendingDestruction.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } pendingDestruction.current.clear(); }; 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 172eb65ac26..3ca813e28a5 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 @@ -3,37 +3,44 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "../../../utils/paneLifecycleRows"; -/** Grace period for cross-workspace pane moves before disposing. */ -const DISPOSE_DELAY_MS = 500; +/** Grace period for cross-workspace pane moves before releasing renderer state. */ +const RELEASE_DELAY_MS = 500; 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 pane of Object.values(tab.panes)) { - if (pane.kind === "terminal") { - const data = pane.data as TerminalPaneData; - if (data.terminalId) { - ids.add(data.terminalId); - } - } - } - } - } - return ids; +interface PendingTerminalRelease { + workspaceId: string; + timer: ReturnType | null; +} + +function getTerminalId( + pane: WorkspaceState["tabs"][number]["panes"][string], +): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as Partial; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function extractTerminalLocations( + rows: PaneLifecycleRow[], +): Map { + return extractPaneLocations(rows, getTerminalId); } export function useGlobalTerminalLifecycle() { const collections = useCollections(); - const prevTerminalIdsRef = useRef>(new Set()); - const pendingDisposals = useRef>>( + const prevTerminalLocationsRef = useRef>(new Map()); + const pendingReleases = useRef>( new Map(), ); @@ -46,46 +53,78 @@ export function useGlobalTerminalLifecycle() { ); useEffect(() => { - const currentTerminalIds = extractTerminalIds(allWorkspaceRows); - const prevTerminalIds = prevTerminalIdsRef.current; - - for (const terminalId of currentTerminalIds) { - const timer = pendingDisposals.current.get(terminalId); - if (timer) { - clearTimeout(timer); - pendingDisposals.current.delete(terminalId); + const rows = allWorkspaceRows as PaneLifecycleRow[]; + const currentTerminalLocations = extractTerminalLocations(rows); + const currentWorkspaceIds = extractWorkspaceIds(rows); + const prevTerminalLocations = prevTerminalLocationsRef.current; + + for (const terminalId of currentTerminalLocations.keys()) { + const pending = pendingReleases.current.get(terminalId); + if (pending?.timer) { + clearTimeout(pending.timer); + } + pendingReleases.current.delete(terminalId); + } + + // If a pane was authoritatively removed but the owner row disappeared + // before the grace timer fired, keep waiting until that row is present + // again. That avoids releasing active renderer state during sleep/wake + // while still cleaning up when the post-removal layout comes back. + for (const [terminalId, pending] of pendingReleases.current) { + if (pending.timer) continue; + if (currentWorkspaceIds.has(pending.workspaceId)) { + pendingReleases.current.delete(terminalId); + terminalRuntimeRegistry.release(terminalId); } } - for (const terminalId of prevTerminalIds) { - if (currentTerminalIds.has(terminalId)) continue; - if (pendingDisposals.current.has(terminalId)) continue; + const removedLocations = getRemovedPaneLocations({ + previousLocations: prevTerminalLocations, + currentLocations: currentTerminalLocations, + currentWorkspaceIds, + }); - const timer = setTimeout(() => { - pendingDisposals.current.delete(terminalId); + for (const { id: terminalId, workspaceId } of removedLocations) { + if (pendingReleases.current.has(terminalId)) continue; + const timer = setTimeout(() => { const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ); - const freshIds = extractTerminalIds(freshRows); + ) as PaneLifecycleRow[]; + const freshLocations = extractTerminalLocations(freshRows); + const freshWorkspaceIds = extractWorkspaceIds(freshRows); - if (!freshIds.has(terminalId)) { - terminalRuntimeRegistry.dispose(terminalId); + if (freshLocations.has(terminalId)) { + pendingReleases.current.delete(terminalId); + return; } - }, DISPOSE_DELAY_MS); - pendingDisposals.current.set(terminalId, timer); + if (freshWorkspaceIds.has(workspaceId)) { + pendingReleases.current.delete(terminalId); + terminalRuntimeRegistry.release(terminalId); + return; + } + + const pending = pendingReleases.current.get(terminalId); + if (pending) { + pending.timer = null; + } + }, RELEASE_DELAY_MS); + + pendingReleases.current.set(terminalId, { workspaceId, timer }); } - prevTerminalIdsRef.current = currentTerminalIds; + prevTerminalLocationsRef.current = currentTerminalLocations; }, [allWorkspaceRows, collections]); useEffect(() => { return () => { - for (const timer of pendingDisposals.current.values()) { - clearTimeout(timer); + for (const pending of pendingReleases.current.values()) { + if (pending.timer) { + clearTimeout(pending.timer); + } } - pendingDisposals.current.clear(); + pendingReleases.current.clear(); }; }, []); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts new file mode 100644 index 00000000000..bd0002dcfd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/index.ts @@ -0,0 +1,8 @@ +export { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, + type RemovedPaneLocation, +} from "./paneLifecycleRows"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts new file mode 100644 index 00000000000..2f87536559a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "bun:test"; +import { + extractPaneIds, + extractPaneLocations, + extractWorkspaceIds, + getRemovedPaneLocations, + type PaneLifecycleRow, +} from "./paneLifecycleRows"; + +function row( + workspaceId: string, + panes: Record, +): PaneLifecycleRow { + return { + workspaceId, + paneLayout: { + tabs: [ + { + id: `${workspaceId}-tab`, + title: "Tab", + panes, + layout: null, + activePaneId: null, + }, + ], + activeTabId: `${workspaceId}-tab`, + }, + }; +} + +function terminalPane(id: string) { + return { + id: `pane-${id}`, + kind: "terminal", + data: { terminalId: id }, + }; +} + +function terminalIdForPane(pane: { + kind: string; + data: unknown; +}): string | null { + if (pane.kind !== "terminal") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +describe("paneLifecycleRows", () => { + test("extracts workspace IDs and tracked pane locations", () => { + const rows = [ + row("workspace-a", { + "pane-term-1": terminalPane("term-1"), + "pane-file-1": { id: "pane-file-1", kind: "file", data: {} }, + }), + row("workspace-b", { + "pane-term-2": terminalPane("term-2"), + }), + ]; + + expect([...extractWorkspaceIds(rows)]).toEqual([ + "workspace-a", + "workspace-b", + ]); + expect([ + ...extractPaneLocations(rows, terminalIdForPane).entries(), + ]).toEqual([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + expect([...extractPaneIds(rows, terminalIdForPane)]).toEqual([ + "term-1", + "term-2", + ]); + }); + + test("marks a pane removed only when its owner workspace row is present", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-a", "workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([{ id: "term-1", workspaceId: "workspace-a" }]); + }); + + test("ignores panes whose owner workspace row disappeared", () => { + const previousLocations = new Map([ + ["term-1", "workspace-a"], + ["term-2", "workspace-b"], + ]); + const currentLocations = new Map([["term-2", "workspace-b"]]); + const currentWorkspaceIds = new Set(["workspace-b"]); + + expect( + getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, + }), + ).toEqual([]); + }); +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts new file mode 100644 index 00000000000..e5873152b24 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/utils/paneLifecycleRows/paneLifecycleRows.ts @@ -0,0 +1,93 @@ +import type { Pane, WorkspaceState } from "@superset/panes"; + +export interface PaneLifecycleRow { + workspaceId: unknown; + paneLayout: unknown; +} + +export interface RemovedPaneLocation { + id: string; + workspaceId: string; +} + +export function extractWorkspaceIds(rows: PaneLifecycleRow[]): Set { + const workspaceIds = new Set(); + for (const row of rows) { + if (typeof row.workspaceId === "string") { + workspaceIds.add(row.workspaceId); + } + } + return workspaceIds; +} + +export function extractPaneLocations( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): Map { + const locations = new Map(); + + for (const row of rows) { + if (typeof row.workspaceId !== "string") continue; + + const layout = row.paneLayout as WorkspaceState | undefined; + if (!layout?.tabs) continue; + + for (const tab of layout.tabs) { + for (const pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + locations.set(trackedPaneId, row.workspaceId); + } + } + } + } + + return locations; +} + +export function extractPaneIds( + rows: PaneLifecycleRow[], + getTrackedPaneId: (pane: Pane) => string | null, +): 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 pane of Object.values(tab.panes)) { + const trackedPaneId = getTrackedPaneId(pane); + if (trackedPaneId) { + ids.add(trackedPaneId); + } + } + } + } + + return ids; +} + +export function getRemovedPaneLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, +}: { + previousLocations: Map; + currentLocations: Map; + currentWorkspaceIds: Set; +}): RemovedPaneLocation[] { + const removed: RemovedPaneLocation[] = []; + + for (const [id, workspaceId] of previousLocations) { + if (currentLocations.has(id)) continue; + // A missing owner row means the collection snapshot is not authoritative + // for this pane. This happens during org/provider churn and can happen + // briefly after laptop sleep/wake. Intentional sidebar-row removals clean + // up their pane runtimes before deleting the row. + if (!currentWorkspaceIds.has(workspaceId)) continue; + removed.push({ id, workspaceId }); + } + + return removed; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 438d1b5be51..2cbb3417b05 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -1,5 +1,11 @@ -import type { WorkspaceState } from "@superset/panes"; +import type { Pane, WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; +import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; +import { browserRuntimeRegistry } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/BrowserPane/browserRuntimeRegistry"; +import { + extractPaneIds, + type PaneLifecycleRow, +} from "renderer/routes/_authenticated/components/utils/paneLifecycleRows"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; @@ -80,6 +86,26 @@ function ensureSidebarWorkspaceRecord( }); } +function getTerminalRuntimeId(pane: Pane): string | null { + if (pane.kind !== "terminal") return null; + if (!pane.data || typeof pane.data !== "object") return null; + const data = pane.data as { terminalId?: unknown }; + return typeof data.terminalId === "string" ? data.terminalId : null; +} + +function getBrowserRuntimeId(pane: Pane): string | null { + return pane.kind === "browser" ? pane.id : null; +} + +function cleanupWorkspacePaneRuntimes(rows: PaneLifecycleRow[]): void { + for (const terminalId of extractPaneIds(rows, getTerminalRuntimeId)) { + terminalRuntimeRegistry.release(terminalId); + } + for (const browserId of extractPaneIds(rows, getBrowserRuntimeId)) { + browserRuntimeRegistry.destroy(browserId); + } +} + export function useDashboardSidebarState() { const collections = useCollections(); @@ -403,7 +429,9 @@ export function useDashboardSidebarState() { const removeWorkspaceFromSidebar = useCallback( (workspaceId: string) => { - if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + const workspace = collections.v2WorkspaceLocalState.get(workspaceId); + if (!workspace) return; + cleanupWorkspacePaneRuntimes([workspace]); collections.v2WorkspaceLocalState.delete(workspaceId); }, [collections], @@ -411,11 +439,10 @@ export function useDashboardSidebarState() { const removeProjectFromSidebar = useCallback( (projectId: string) => { - const workspaceIds = Array.from( + const workspaceRows = Array.from( collections.v2WorkspaceLocalState.state.values(), - ) - .filter((item) => item.sidebarState.projectId === projectId) - .map((item) => item.workspaceId); + ).filter((item) => item.sidebarState.projectId === projectId); + const workspaceIds = workspaceRows.map((item) => item.workspaceId); const sectionIds = Array.from( collections.v2SidebarSections.state.values(), ) @@ -423,6 +450,7 @@ export function useDashboardSidebarState() { .map((item) => item.sectionId); if (workspaceIds.length > 0) { + cleanupWorkspacePaneRuntimes(workspaceRows); collections.v2WorkspaceLocalState.delete(workspaceIds); } if (sectionIds.length > 0) { diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index c90dfd7253b..34879e11312 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { createTerminalSessionInternal, + disposeSession, parseThemeType, } from "../../../terminal/terminal"; import { protectedProcedure, router } from "../../index"; @@ -34,4 +35,15 @@ export const terminalRouter = router({ return { terminalId: result.terminalId, status: "active" as const }; }), + + killSession: protectedProcedure + .input( + z.object({ + terminalId: z.string(), + }), + ) + .mutation(({ ctx, input }) => { + disposeSession(input.terminalId, ctx.db); + return { terminalId: input.terminalId, status: "disposed" as const }; + }), }); From 4835202f9aca106c9cad4c62cb4c888a1f071a18 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 18:20:38 -0700 Subject: [PATCH 2/8] add v2 terminal session dropdown --- .../TerminalSessionDropdown.tsx | 240 ++++++++++++++++++ .../TerminalSessionDropdown/index.ts | 1 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 6 + .../src/runtime/teardown/teardown.ts | 1 + .../host-service/src/terminal/terminal.ts | 49 +++- .../src/trpc/router/terminal/terminal.ts | 14 + 6 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx new file mode 100644 index 00000000000..e60f6752fb4 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -0,0 +1,240 @@ +import type { RendererContext } from "@superset/panes"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { + Check, + ChevronDown, + LoaderCircle, + Plus, + TerminalSquare, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +interface TerminalSessionDropdownProps { + context: RendererContext; + workspaceId: string; +} + +interface VisibleTerminalSession { + terminalId: string; + workspaceId: string; + exited: boolean; + exitCode: number; + attached: boolean; + pending?: boolean; +} + +interface TerminalPaneLocation { + tabId: string; + paneId: string; + titleOverride?: string; +} + +function getShortTerminalId(terminalId: string): string { + return terminalId.length <= 8 ? terminalId : terminalId.slice(0, 8); +} + +function findTerminalPaneLocation( + context: RendererContext, + terminalId: string, +): TerminalPaneLocation | null { + for (const tab of context.store.getState().tabs) { + for (const pane of Object.values(tab.panes)) { + if (pane.id === context.pane.id || pane.kind !== "terminal") continue; + const data = pane.data as Partial; + if (data.terminalId === terminalId) { + return { + tabId: tab.id, + paneId: pane.id, + titleOverride: pane.titleOverride, + }; + } + } + } + return null; +} + +export function TerminalSessionDropdown({ + context, + workspaceId, +}: TerminalSessionDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const data = context.pane.data as TerminalPaneData; + const { terminalId } = data; + const utils = workspaceTrpc.useUtils(); + const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( + { workspaceId }, + { + enabled: isOpen, + refetchInterval: isOpen ? 2_000 : false, + refetchOnWindowFocus: true, + }, + ); + + const sessions = useMemo(() => { + const liveSessions = sessionsQuery.data?.sessions ?? []; + if (liveSessions.some((session) => session.terminalId === terminalId)) { + return liveSessions; + } + return [ + { + terminalId, + workspaceId, + exited: false, + exitCode: 0, + attached: false, + pending: true, + }, + ...liveSessions, + ]; + }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); + + const handleSelectSession = (nextTerminalId: string) => { + if (nextTerminalId === terminalId) { + setIsOpen(false); + return; + } + + const state = context.store.getState(); + const existingLocation = findTerminalPaneLocation(context, nextTerminalId); + if (existingLocation) { + state.setPaneData({ + paneId: existingLocation.paneId, + data: { terminalId } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: existingLocation.tabId, + paneId: existingLocation.paneId, + titleOverride: context.pane.titleOverride, + }); + } + + state.setPaneData({ + paneId: context.pane.id, + data: { terminalId: nextTerminalId } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: existingLocation?.titleOverride, + }); + setIsOpen(false); + }; + + const handleNewTerminal = () => { + const state = context.store.getState(); + state.setPaneData({ + paneId: context.pane.id, + data: { + terminalId: crypto.randomUUID(), + } as PaneViewerData, + }); + state.setPaneTitleOverride({ + tabId: context.tab.id, + paneId: context.pane.id, + titleOverride: undefined, + }); + void utils.terminal.listSessions.invalidate({ workspaceId }); + setIsOpen(false); + }; + + const triggerTitle = context.pane.titleOverride ?? "Terminal"; + + return ( + + + + + + + Terminal Sessions + + +
+ {sessions.length > 0 ? ( + sessions.map((session) => { + const isCurrent = session.terminalId === terminalId; + const location = findTerminalPaneLocation( + context, + session.terminalId, + ); + const canSelect = + isCurrent || !session.attached || location !== null; + const status = isCurrent + ? "Current" + : location + ? "Swap" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; + const title = isCurrent + ? triggerTitle + : (location?.titleOverride ?? "Terminal"); + + return ( + handleSelectSession(session.terminalId)} + > + + {isCurrent && } + + + {title} + + + {getShortTerminalId(session.terminalId)} + + + {status} + + + ); + }) + ) : ( +
+ No live sessions +
+ )} +
+ + + + New Terminal + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts new file mode 100644 index 00000000000..320b21eb495 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/index.ts @@ -0,0 +1 @@ +export { TerminalSessionDropdown } from "./TerminalSessionDropdown"; 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 351e1bb13d6..7c0f871c0ae 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 @@ -49,6 +49,7 @@ import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; +import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; function getFileName(filePath: string): string { return filePath.split("/").pop() ?? filePath; @@ -148,9 +149,11 @@ export function usePaneRegistry( ): PaneRegistry { const clearShortcut = useHotkeyDisplay("CLEAR_TERMINAL").text; const scrollToBottomShortcut = useHotkeyDisplay("SCROLL_TO_BOTTOM").text; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation({ onSuccess: () => { toast.success("Terminal session killed"); + void workspaceTrpcUtils.terminal.listSessions.invalidate({ workspaceId }); }, onError: (error) => { toast.error("Failed to kill terminal session", { @@ -249,6 +252,9 @@ export function usePaneRegistry( terminal: { getIcon: () => , getTitle: () => "Terminal", + renderTitle: (ctx: RendererContext) => ( + + ), renderPane: (ctx: RendererContext) => ( void; @@ -77,6 +78,7 @@ interface TerminalSession { exited: boolean; exitCode: number; exitSignal: number; + listed: boolean; // Shell readiness (OSC 133) shellReadyState: ShellReadyState; @@ -89,6 +91,36 @@ interface TerminalSession { /** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ const sessions = new Map(); +export interface TerminalSessionSummary { + terminalId: string; + workspaceId: string; + exited: boolean; + exitCode: number; + attached: boolean; +} + +export function listTerminalSessions( + options: { workspaceId?: string; includeExited?: boolean } = {}, +): TerminalSessionSummary[] { + const includeExited = options.includeExited ?? true; + + return Array.from(sessions.values()) + .filter((session) => session.listed) + .filter( + (session) => + options.workspaceId === undefined || + session.workspaceId === options.workspaceId, + ) + .filter((session) => includeExited || !session.exited) + .map((session) => ({ + terminalId: session.terminalId, + workspaceId: session.workspaceId, + exited: session.exited, + exitCode: session.exitCode, + attached: session.socket !== null, + })); +} + function sendMessage( socket: { send: (data: string) => void; readyState: number }, message: TerminalServerMessage, @@ -213,6 +245,8 @@ interface CreateTerminalSessionOptions { db: HostDb; /** Command to run after the shell is ready. Queued behind shellReadyPromise. */ initialCommand?: string; + /** Hidden sessions are process-internal and should not appear in user pickers. */ + listed?: boolean; } export function createTerminalSessionInternal({ @@ -221,9 +255,11 @@ export function createTerminalSessionInternal({ themeType, db, initialCommand, + listed = true, }: CreateTerminalSessionOptions): TerminalSession | { error: string } { const existing = sessions.get(terminalId); if (existing) { + if (listed) existing.listed = true; return existing; } @@ -309,6 +345,7 @@ export function createTerminalSessionInternal({ const session: TerminalSession = { terminalId, + workspaceId, pty, socket: null, buffer: [], @@ -316,6 +353,7 @@ export function createTerminalSessionInternal({ exited: false, exitCode: 0, exitSignal: 0, + listed, shellReadyState: shellSupportsReady ? "pending" : "unsupported", shellReadyResolve, shellReadyPromise, @@ -432,13 +470,10 @@ export function registerWorkspaceTerminalRoute({ // REST list — enumerate live terminal sessions app.get("/terminal/sessions", (c) => { - const result = Array.from(sessions.values()).map((s) => ({ - terminalId: s.terminalId, - exited: s.exited, - exitCode: s.exitCode, - attached: s.socket !== null, - })); - return c.json({ sessions: result }); + const workspaceId = c.req.query("workspaceId") || undefined; + return c.json({ + sessions: listTerminalSessions({ workspaceId, includeExited: true }), + }); }); app.get( diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index 34879e11312..25af3827e58 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { createTerminalSessionInternal, disposeSession, + listTerminalSessions, parseThemeType, } from "../../../terminal/terminal"; import { protectedProcedure, router } from "../../index"; @@ -36,6 +37,19 @@ export const terminalRouter = router({ return { terminalId: result.terminalId, status: "active" as const }; }), + listSessions: protectedProcedure + .input( + z.object({ + workspaceId: z.string(), + }), + ) + .query(({ input }) => ({ + sessions: listTerminalSessions({ + workspaceId: input.workspaceId, + includeExited: false, + }), + })), + killSession: protectedProcedure .input( z.object({ From 688b37c1d850cdff6835db1939663b70b92631e4 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 24 Apr 2026 19:05:07 -0700 Subject: [PATCH 3/8] make terminal close kill sessions explicitly --- .../terminal/terminal-background-intents.ts | 9 ++ .../TerminalHeaderExtras.tsx | 42 +++++++ .../components/TerminalHeaderExtras/index.ts | 1 + .../TerminalSessionDropdown.tsx | 71 ++++++++++- .../hooks/usePaneRegistry/usePaneRegistry.tsx | 9 +- .../useGlobalTerminalLifecycle.ts | 119 +++++++++++++++--- .../renderer/routes/_authenticated/layout.tsx | 2 +- 7 files changed, 228 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts new file mode 100644 index 00000000000..acc7662c1ee --- /dev/null +++ b/apps/desktop/src/renderer/lib/terminal/terminal-background-intents.ts @@ -0,0 +1,9 @@ +const backgroundTerminalIds = new Set(); + +export function markTerminalForBackground(terminalId: string): void { + backgroundTerminalIds.add(terminalId); +} + +export function consumeTerminalBackgroundIntent(terminalId: string): boolean { + return backgroundTerminalIds.delete(terminalId); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx new file mode 100644 index 00000000000..0b1fbe9d1f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/TerminalHeaderExtras.tsx @@ -0,0 +1,42 @@ +import type { RendererContext } from "@superset/panes"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { Archive } from "lucide-react"; +import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; + +interface TerminalHeaderExtrasProps { + context: RendererContext; +} + +export function TerminalHeaderExtras({ context }: TerminalHeaderExtrasProps) { + const data = context.pane.data as TerminalPaneData; + + const handleMoveToBackground = () => { + markTerminalForBackground(data.terminalId); + void context.actions.close(); + }; + + return ( + + + + + + Move terminal to background + + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts new file mode 100644 index 00000000000..7f654de91f5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalHeaderExtras/index.ts @@ -0,0 +1 @@ +export { TerminalHeaderExtras } from "./TerminalHeaderExtras"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index e60f6752fb4..959cd2ed2b1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -1,4 +1,5 @@ import type { RendererContext } from "@superset/panes"; +import { alert } from "@superset/ui/atoms/Alert"; import { DropdownMenu, DropdownMenuContent, @@ -7,6 +8,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; import { Check, @@ -14,6 +16,7 @@ import { LoaderCircle, Plus, TerminalSquare, + Trash2, } from "lucide-react"; import { useMemo, useState } from "react"; import type { @@ -73,6 +76,7 @@ export function TerminalSessionDropdown({ const data = context.pane.data as TerminalPaneData; const { terminalId } = data; const utils = workspaceTrpc.useUtils(); + const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( { workspaceId }, { @@ -132,6 +136,49 @@ export function TerminalSessionDropdown({ setIsOpen(false); }; + const closePaneForTerminal = (targetTerminalId: string) => { + if (targetTerminalId === terminalId) { + void context.actions.close(); + return; + } + + const location = findTerminalPaneLocation(context, targetTerminalId); + if (!location) return; + + context.store.getState().closePane({ + tabId: location.tabId, + paneId: location.paneId, + }); + }; + + const removeTerminalSession = async (targetTerminalId: string) => { + await killTerminalSession.mutateAsync({ terminalId: targetTerminalId }); + closePaneForTerminal(targetTerminalId); + await utils.terminal.listSessions.invalidate({ workspaceId }); + }; + + const handleRemoveTerminal = (targetTerminalId: string) => { + alert({ + title: "Remove terminal session?", + description: + "This will terminate the underlying process. Use Move terminal to background to keep it running without a pane.", + actions: [ + { label: "Cancel", variant: "outline", onClick: () => {} }, + { + label: "Remove Terminal", + variant: "destructive", + onClick: () => { + toast.promise(removeTerminalSession(targetTerminalId), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", + }); + }, + }, + ], + }); + }; + const handleNewTerminal = () => { const state = context.store.getState(); state.setPaneData({ @@ -204,9 +251,14 @@ export function TerminalSessionDropdown({ return ( handleSelectSession(session.terminalId)} + className={`group flex items-center gap-2 ${!canSelect ? "text-muted-foreground/50" : ""}`} + onSelect={(event) => { + if (!canSelect) { + event.preventDefault(); + return; + } + handleSelectSession(session.terminalId); + }} > {isCurrent && } @@ -220,6 +272,19 @@ export function TerminalSessionDropdown({ {status} + ); }) 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 7c0f871c0ae..48b15f92b80 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 @@ -49,6 +49,7 @@ import { DiffPane } from "./components/DiffPane"; import { FilePane } from "./components/FilePane"; import { FilePaneHeaderExtras } from "./components/FilePane/components/FilePaneHeaderExtras"; import { TerminalPane } from "./components/TerminalPane"; +import { TerminalHeaderExtras } from "./components/TerminalPane/components/TerminalHeaderExtras"; import { TerminalSessionDropdown } from "./components/TerminalPane/components/TerminalSessionDropdown"; function getFileName(filePath: string): string { @@ -255,6 +256,9 @@ export function usePaneRegistry( renderTitle: (ctx: RendererContext) => ( ), + renderHeaderExtras: (ctx: RendererContext) => ( + + ), renderPane: (ctx: RendererContext) => ( - d.key === "close-pane" ? { ...d, label: "Close Terminal Pane" } : d, + d.key === "close-pane" ? { ...d, label: "Close Terminal" } : d, ); const killAction: ContextMenuActionConfig = { @@ -339,7 +342,7 @@ export function usePaneRegistry( alert({ title: "Kill terminal session?", description: - "This will terminate the underlying process. Closing the pane only detaches the view.", + "This will terminate the underlying process. Move the terminal to background to keep it running without a pane.", actions: [ { label: "Cancel", variant: "outline", onClick: () => {} }, { 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 3ca813e28a5..2ee9a535753 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 @@ -1,8 +1,13 @@ import type { WorkspaceState } from "@superset/panes"; +import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; +import { env } from "renderer/env.renderer"; +import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { consumeTerminalBackgroundIntent } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { useLocalHostService } from "renderer/routes/_authenticated/providers/LocalHostServiceProvider"; import { extractPaneLocations, extractWorkspaceIds, @@ -10,14 +15,14 @@ import { type PaneLifecycleRow, } from "../../../utils/paneLifecycleRows"; -/** Grace period for cross-workspace pane moves before releasing renderer state. */ +/** Grace period for cross-workspace pane moves before terminal cleanup. */ const RELEASE_DELAY_MS = 500; interface TerminalPaneData { terminalId: string; } -interface PendingTerminalRelease { +interface PendingTerminalCleanup { workspaceId: string; timer: ReturnType | null; } @@ -37,10 +42,45 @@ function extractTerminalLocations( return extractPaneLocations(rows, getTerminalId); } +function cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId, +}: { + terminalId: string; + workspaceId: string; + hostUrlByWorkspaceId: Map; +}) { + if (consumeTerminalBackgroundIntent(terminalId)) { + terminalRuntimeRegistry.release(terminalId); + return; + } + + terminalRuntimeRegistry.dispose(terminalId); + const hostUrl = hostUrlByWorkspaceId.get(workspaceId); + if (!hostUrl) { + console.warn( + "[GlobalTerminalLifecycle] Missing host URL while killing removed terminal", + { terminalId, workspaceId }, + ); + return; + } + + getHostServiceClientByUrl(hostUrl) + .terminal.killSession.mutate({ terminalId }) + .catch((error) => { + console.warn( + "[GlobalTerminalLifecycle] Failed to kill removed terminal", + { terminalId, workspaceId, error }, + ); + }); +} + export function useGlobalTerminalLifecycle() { const collections = useCollections(); + const { machineId, activeHostUrl } = useLocalHostService(); const prevTerminalLocationsRef = useRef>(new Map()); - const pendingReleases = useRef>( + const pendingCleanups = useRef>( new Map(), ); @@ -52,6 +92,41 @@ export function useGlobalTerminalLifecycle() { [collections], ); + const { data: workspacesWithHosts = [] } = useLiveQuery( + (query) => + query + .from({ v2Workspaces: collections.v2Workspaces }) + .leftJoin({ hosts: collections.v2Hosts }, ({ v2Workspaces, hosts }) => + eq(v2Workspaces.hostId, hosts.id), + ) + .select(({ v2Workspaces, hosts }) => ({ + workspaceId: v2Workspaces.id, + hostId: v2Workspaces.hostId, + hostMachineId: hosts?.machineId ?? null, + })), + [collections], + ); + + const hostUrlByWorkspaceId = useMemo(() => { + const urls = new Map(); + for (const workspace of workspacesWithHosts) { + if (workspace.hostMachineId === machineId) { + if (activeHostUrl) { + urls.set(workspace.workspaceId, activeHostUrl); + } + continue; + } + + if (workspace.hostId) { + urls.set( + workspace.workspaceId, + `${env.RELAY_URL}/hosts/${workspace.hostId}`, + ); + } + } + return urls; + }, [activeHostUrl, machineId, workspacesWithHosts]); + useEffect(() => { const rows = allWorkspaceRows as PaneLifecycleRow[]; const currentTerminalLocations = extractTerminalLocations(rows); @@ -59,22 +134,26 @@ export function useGlobalTerminalLifecycle() { const prevTerminalLocations = prevTerminalLocationsRef.current; for (const terminalId of currentTerminalLocations.keys()) { - const pending = pendingReleases.current.get(terminalId); + const pending = pendingCleanups.current.get(terminalId); if (pending?.timer) { clearTimeout(pending.timer); } - pendingReleases.current.delete(terminalId); + pendingCleanups.current.delete(terminalId); } // If a pane was authoritatively removed but the owner row disappeared // before the grace timer fired, keep waiting until that row is present // again. That avoids releasing active renderer state during sleep/wake // while still cleaning up when the post-removal layout comes back. - for (const [terminalId, pending] of pendingReleases.current) { + for (const [terminalId, pending] of pendingCleanups.current) { if (pending.timer) continue; if (currentWorkspaceIds.has(pending.workspaceId)) { - pendingReleases.current.delete(terminalId); - terminalRuntimeRegistry.release(terminalId); + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId: pending.workspaceId, + hostUrlByWorkspaceId, + }); } } @@ -85,7 +164,7 @@ export function useGlobalTerminalLifecycle() { }); for (const { id: terminalId, workspaceId } of removedLocations) { - if (pendingReleases.current.has(terminalId)) continue; + if (pendingCleanups.current.has(terminalId)) continue; const timer = setTimeout(() => { const freshRows = Array.from( @@ -95,36 +174,40 @@ export function useGlobalTerminalLifecycle() { const freshWorkspaceIds = extractWorkspaceIds(freshRows); if (freshLocations.has(terminalId)) { - pendingReleases.current.delete(terminalId); + pendingCleanups.current.delete(terminalId); return; } if (freshWorkspaceIds.has(workspaceId)) { - pendingReleases.current.delete(terminalId); - terminalRuntimeRegistry.release(terminalId); + pendingCleanups.current.delete(terminalId); + cleanupRemovedTerminal({ + terminalId, + workspaceId, + hostUrlByWorkspaceId, + }); return; } - const pending = pendingReleases.current.get(terminalId); + const pending = pendingCleanups.current.get(terminalId); if (pending) { pending.timer = null; } }, RELEASE_DELAY_MS); - pendingReleases.current.set(terminalId, { workspaceId, timer }); + pendingCleanups.current.set(terminalId, { workspaceId, timer }); } prevTerminalLocationsRef.current = currentTerminalLocations; - }, [allWorkspaceRows, collections]); + }, [allWorkspaceRows, collections, hostUrlByWorkspaceId]); useEffect(() => { return () => { - for (const pending of pendingReleases.current.values()) { + for (const pending of pendingCleanups.current.values()) { if (pending.timer) { clearTimeout(pending.timer); } } - pendingReleases.current.clear(); + pendingCleanups.current.clear(); }; }, []); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index cb100039fc1..96e54851be1 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -183,9 +183,9 @@ function AuthenticatedLayout() { return ( - + Date: Fri, 24 Apr 2026 19:11:54 -0700 Subject: [PATCH 4/8] show terminal session create time --- .../components/TerminalPane/TerminalPane.tsx | 13 ++++++ .../TerminalSessionDropdown.tsx | 43 +++++++++++++++---- .../host-service/src/terminal/terminal.ts | 9 +++- 3 files changed, 56 insertions(+), 9 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 a41d06890cf..8c80d2ad453 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 @@ -82,6 +82,12 @@ export function TerminalPane({ const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); const ensureSessionRef = useRef(ensureSession); ensureSessionRef.current = ensureSession; + const workspaceTrpcUtils = workspaceTrpc.useUtils(); + const invalidateTerminalSessionsRef = useRef( + workspaceTrpcUtils.terminal.listSessions.invalidate, + ); + invalidateTerminalSessionsRef.current = + workspaceTrpcUtils.terminal.listSessions.invalidate; // useCallback so useSyncExternalStore doesn't re-subscribe every render — // otherwise every keystroke-triggered re-render unsubscribes and @@ -129,6 +135,13 @@ export function TerminalPane({ workspaceId: workspaceIdRef.current, themeType: initialThemeTypeRef.current, }) + .then((result) => { + if (result.status === "active") { + void invalidateTerminalSessionsRef.current({ + workspaceId: workspaceIdRef.current, + }); + } + }) .catch((err) => { console.error("[TerminalPane] ensureSession failed:", err); }) diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx index 959cd2ed2b1..dc14a5c24f7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/components/TerminalSessionDropdown/TerminalSessionDropdown.tsx @@ -32,6 +32,7 @@ interface TerminalSessionDropdownProps { interface VisibleTerminalSession { terminalId: string; workspaceId: string; + createdAt?: number; exited: boolean; exitCode: number; attached: boolean; @@ -44,8 +45,26 @@ interface TerminalPaneLocation { titleOverride?: string; } -function getShortTerminalId(terminalId: string): string { - return terminalId.length <= 8 ? terminalId : terminalId.slice(0, 8); +function isSameCalendarDay(date: Date, reference: Date): boolean { + return ( + date.getFullYear() === reference.getFullYear() && + date.getMonth() === reference.getMonth() && + date.getDate() === reference.getDate() + ); +} + +function formatCreatedAt(createdAt: number | undefined): string { + if (!createdAt) return "Creating"; + + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return "Creating"; + + const today = new Date(); + const options: Intl.DateTimeFormatOptions = isSameCalendarDay(date, today) + ? { hour: "numeric", minute: "2-digit" } + : { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }; + + return new Intl.DateTimeFormat(undefined, options).format(date); } function findTerminalPaneLocation( @@ -80,7 +99,6 @@ export function TerminalSessionDropdown({ const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( { workspaceId }, { - enabled: isOpen, refetchInterval: isOpen ? 2_000 : false, refetchOnWindowFocus: true, }, @@ -197,6 +215,10 @@ export function TerminalSessionDropdown({ }; const triggerTitle = context.pane.titleOverride ?? "Terminal"; + const currentSession = sessions.find( + (session) => session.terminalId === terminalId, + ); + const currentCreatedAtLabel = formatCreatedAt(currentSession?.createdAt); return ( @@ -210,8 +232,10 @@ export function TerminalSessionDropdown({ > {triggerTitle} - - {getShortTerminalId(terminalId)} + + {currentSession?.createdAt + ? `Created ${currentCreatedAtLabel}` + : currentCreatedAtLabel} {sessionsQuery.isFetching && isOpen ? ( @@ -235,6 +259,7 @@ export function TerminalSessionDropdown({ ); const canSelect = isCurrent || !session.attached || location !== null; + const createdAtLabel = formatCreatedAt(session.createdAt); const status = isCurrent ? "Current" : location @@ -266,15 +291,17 @@ export function TerminalSessionDropdown({ {title} - - {getShortTerminalId(session.terminalId)} + + {session.createdAt + ? `Created ${createdAtLabel}` + : createdAtLabel} {status}