diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts index d51e1ff605b..5a64dfce8e2 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-addons.ts @@ -11,14 +11,9 @@ import { installRectangleRendererAlphaPatch } from "./webgl-vibrancy-patch"; export interface LoadAddonsResult { searchAddon: SearchAddon; progressAddon: ProgressAddon; - clearTextureAtlas: () => void; dispose: () => void; } -interface LoadAddonsOptions { - onRendererChange?: () => void; -} - // Once WebGL fails, skip it for all subsequent runtimes (VS Code pattern). let suggestedRendererType: "webgl" | "dom" | undefined; @@ -27,10 +22,7 @@ let suggestedRendererType: "webgl" | "dom" | undefined; * function and addon instances. WebGL is deferred to rAF to avoid * racing with xterm's post-open viewport sync. */ -export function loadAddons( - terminal: XTerm, - options: LoadAddonsOptions = {}, -): LoadAddonsResult { +export function loadAddons(terminal: XTerm): LoadAddonsResult { let disposed = false; let webglAddon: WebglAddon | null = null; @@ -60,7 +52,6 @@ export function loadAddons( webglAddon.onContextLoss(() => { webglAddon?.dispose(); webglAddon = null; - options.onRendererChange?.(); terminal.refresh(0, terminal.rows - 1); }); terminal.loadAddon(webglAddon); @@ -69,7 +60,6 @@ export function loadAddons( // Claude Code TUI blocks render as opaque black even though the // rest of the terminal is transparent. See `webgl-vibrancy-patch.ts`. installRectangleRendererAlphaPatch(webglAddon); - options.onRendererChange?.(); } catch { suggestedRendererType = "dom"; webglAddon = null; @@ -79,11 +69,6 @@ export function loadAddons( return { searchAddon, progressAddon, - clearTextureAtlas: () => { - try { - webglAddon?.clearTextureAtlas(); - } catch {} - }, dispose: () => { disposed = true; cancelAnimationFrame(rafId); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 79d8d29f01d..b08487ef356 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -20,8 +20,6 @@ const DIMS_KEY_PREFIX = "terminal-dims:"; const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; const RESIZE_DEBOUNCE_MS = 75; -const FONT_SETTLE_TIMEOUT_MS = 1000; -const FONT_LOAD_SAMPLE_TEXT = "W"; // xterm's _keyDown calls stopPropagation after processing, so any chord we // want the host (react-hotkeys-hook, Electron menu accelerators) or the shell @@ -89,10 +87,6 @@ export interface TerminalRuntime { container: HTMLDivElement | null; resizeObserver: ResizeObserver | null; _disposeResizeObserver: (() => void) | null; - _disposeFontSettle: (() => void) | null; - _onResize: (() => void) | null; - _clearTextureAtlas: (() => void) | null; - _fontSettleToken: number; lastCols: number; lastRows: number; _disposeAddons: (() => void) | null; @@ -191,53 +185,6 @@ function hostIsVisible(container: HTMLDivElement | null): boolean { return container.clientWidth > 0 && container.clientHeight > 0; } -function waitForNextFrame(): Promise { - if (typeof requestAnimationFrame !== "function") { - return Promise.resolve(); - } - return new Promise((resolve) => requestAnimationFrame(() => resolve())); -} - -function waitForTerminalFont( - terminal: XTerm, - timeoutMs = FONT_SETTLE_TIMEOUT_MS, -): Promise { - const fontFamily = terminal.options.fontFamily; - const fontSize = terminal.options.fontSize; - if ( - typeof document === "undefined" || - !("fonts" in document) || - typeof fontFamily !== "string" || - typeof fontSize !== "number" - ) { - return waitForNextFrame(); - } - - const fontSpec = `${fontSize}px ${fontFamily}`; - let timeoutId: ReturnType | null = null; - const timeout = new Promise((resolve) => { - timeoutId = setTimeout(resolve, timeoutMs); - }); - let fontLoad: Promise; - try { - fontLoad = document.fonts - .load(fontSpec, FONT_LOAD_SAMPLE_TEXT) - .catch(() => document.fonts.ready) - .then(() => undefined) - .catch(() => undefined); - } catch { - fontLoad = document.fonts.ready - .then(() => undefined) - .catch(() => undefined); - } - - return Promise.race([fontLoad, timeout]) - .then(() => waitForNextFrame()) - .finally(() => { - if (timeoutId !== null) clearTimeout(timeoutId); - }); -} - // Body-level hidden container that owns wrapper divs of terminals whose // React component is currently unmounted (e.g. workspace switch). Keeps // xterm attached to the document so it survives provider remounts without @@ -293,33 +240,6 @@ function measureAndResize(runtime: TerminalRuntime): boolean { return terminal.cols !== prevCols || terminal.rows !== prevRows; } -function scheduleFontSettleRefit(runtime: TerminalRuntime) { - runtime._disposeFontSettle?.(); - - let disposed = false; - const token = runtime._fontSettleToken + 1; - runtime._fontSettleToken = token; - - runtime._disposeFontSettle = () => { - disposed = true; - if (runtime._fontSettleToken === token) { - runtime._disposeFontSettle = null; - } - }; - - void waitForTerminalFont(runtime.terminal).then(() => { - if (disposed || runtime._fontSettleToken !== token) return; - runtime._disposeFontSettle = null; - if (!hostIsVisible(runtime.container)) return; - - // A late-loading font can change cell metrics after xterm's first fit. - runtime._clearTextureAtlas?.(); - if (measureAndResize(runtime)) { - runtime._onResize?.(); - } - }); -} - function createResizeScheduler( runtime: TerminalRuntime, onResize?: () => void, @@ -383,19 +303,14 @@ export function createRuntime( // Activate Unicode 11 widths (inside loadAddons) before restoring the buffer, // else CJK/emoji/ZWJ widths get baked wrong into the replay. (#3572) - let runtime: TerminalRuntime | null = null; - const addonsResult = loadAddons(terminal, { - onRendererChange: () => { - if (runtime) scheduleFontSettleRefit(runtime); - }, - }); + const addonsResult = loadAddons(terminal); if (options.initialBuffer !== undefined) { terminal.write(options.initialBuffer); } else { restoreBuffer(terminalId, terminal); } - runtime = { + return { terminalId, terminal, fitAddon, @@ -406,15 +321,10 @@ export function createRuntime( container: null, resizeObserver: null, _disposeResizeObserver: null, - _disposeFontSettle: null, - _onResize: null, - _clearTextureAtlas: addonsResult.clearTextureAtlas, - _fontSettleToken: 0, lastCols: cols, lastRows: rows, _disposeAddons: addonsResult.dispose, }; - return runtime; } export function attachToContainer( @@ -425,7 +335,6 @@ export function attachToContainer( // If we're already attached to this exact container, do nothing. Prevents // redundant refresh/focus/fit from transient remounts during provider key // churn — VSCode setVisible() is idempotent for the same host element. - runtime._onResize = onResize ?? null; const sameContainer = runtime.container === container && runtime.wrapper.parentElement === container; @@ -441,7 +350,6 @@ export function attachToContainer( containerHeight: container.clientHeight, }); if (measureAndResize(runtime)) onResize?.(); - scheduleFontSettleRefit(runtime); // Renderer may have skipped frames while the wrapper was detached. // (refresh is now handled inside measureAndResize) @@ -486,9 +394,6 @@ export function detachFromContainer(runtime: TerminalRuntime) { ); runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; - runtime._disposeFontSettle?.(); - runtime._disposeFontSettle = null; - runtime._onResize = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; // Park instead of .remove() so xterm survives the React unmount — @@ -513,7 +418,6 @@ export function updateRuntimeAppearance( terminal.options.fontSize = appearance.fontSize; if (hostIsVisible(runtime.container)) { measureAndResize(runtime); - scheduleFontSettleRefit(runtime); } } } @@ -531,9 +435,6 @@ export function disposeRuntime( runtime._disposeAddons = null; runtime._disposeResizeObserver?.(); runtime._disposeResizeObserver = null; - runtime._disposeFontSettle?.(); - runtime._disposeFontSettle = null; - runtime._onResize = null; runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); 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 620fce80328..2971a1562ca 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 @@ -54,7 +54,6 @@ export function TerminalPane({ onOpenFile, onRevealPath, }: TerminalPaneProps) { - const openInExternalEditor = useOpenInExternalEditor(workspaceId); const { data: fileDragBehavior } = electronTrpc.settings.getFileDragBehavior.useQuery(); const { data: fileOpenMode } = @@ -74,6 +73,11 @@ export function TerminalPane({ () => paneData.terminalId ?? crypto.randomUUID(), [paneData.terminalId], ); + // FORK NOTE: paneData.workspaceId fallback for cross-workspace terminal + // sessions (#3751). Older pane data without workspaceId falls back to + // the current workspace. + const sessionWorkspaceId = paneData.workspaceId ?? workspaceId; + const openInExternalEditor = useOpenInExternalEditor(sessionWorkspaceId); const terminalInstanceId = ctx.pane.id; const containerRef = useRef(null); const activeTheme = useTheme(); @@ -95,8 +99,8 @@ export function TerminalPane({ const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`); const websocketUrlRef = useRef(websocketUrl); websocketUrlRef.current = websocketUrl; - const workspaceIdRef = useRef(workspaceId); - workspaceIdRef.current = workspaceId; + const sessionWorkspaceIdRef = useRef(sessionWorkspaceId); + sessionWorkspaceIdRef.current = sessionWorkspaceId; const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); const ensureSessionRef = useRef(ensureSession); @@ -146,7 +150,7 @@ export function TerminalPane({ // "Session not found." // Deps narrowed to [terminalId] so provider key remount churn (workspaceId // briefly flipping while pane data catches up) doesn't re-run this effect. - // workspaceId / websocketUrl are read through refs. + // sessionWorkspaceId / websocketUrl are read through refs. useEffect(() => { const container = containerRef.current; if (!container) return; @@ -159,7 +163,7 @@ export function TerminalPane({ ); let cancelled = false; - const sessionWorkspaceId = workspaceIdRef.current; + const activeSessionWorkspaceId = sessionWorkspaceIdRef.current; // Always connect after ensureSession settles, even on error: if the // session actually exists on the server (e.g. we raced another client), @@ -169,14 +173,12 @@ export function TerminalPane({ ensureSessionRef.current .mutateAsync({ terminalId, - workspaceId: sessionWorkspaceId, + workspaceId: activeSessionWorkspaceId, themeType: initialThemeTypeRef.current, }) .then((result) => { if (result.status === "active") { - void invalidateTerminalSessionsRef.current({ - workspaceId: sessionWorkspaceId, - }); + void invalidateTerminalSessionsRef.current(); } }) .catch((err) => { @@ -232,7 +234,7 @@ export function TerminalPane({ stat: async (path) => { try { const result = await statPathRef.current({ - workspaceId, + workspaceId: sessionWorkspaceId, path, }); if (!result) return null; @@ -303,7 +305,7 @@ export function TerminalPane({ }, [ terminalId, terminalInstanceId, - workspaceId, + sessionWorkspaceId, ctx.store, onOpenFile, onRevealPath, 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 25cc4229f78..02be6f71c2b 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,5 +1,4 @@ import type { RendererContext } from "@superset/panes"; -import { alert } from "@superset/ui/atoms/Alert"; import { DropdownMenu, DropdownMenuContent, @@ -10,6 +9,8 @@ import { } from "@superset/ui/dropdown-menu"; import { toast } from "@superset/ui/sonner"; import { workspaceTrpc } from "@superset/workspace-client"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; import { Check, ChevronDown, @@ -18,13 +19,20 @@ import { TerminalSquare, Trash2, } from "lucide-react"; -import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { + Fragment, + useCallback, + useMemo, + useState, + useSyncExternalStore, +} from "react"; import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import type { PaneViewerData, TerminalPaneData, } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils"; interface TerminalSessionDropdownProps { @@ -49,6 +57,12 @@ interface TerminalPaneLocation { titleOverride?: string; } +interface TerminalSessionGroup { + workspaceId: string; + label: string; + sessions: VisibleTerminalSession[]; +} + const EMPTY_TERMINAL_PANE_LOCATIONS = new Map(); function formatCreatedAt(createdAt: number | undefined): string { @@ -86,16 +100,38 @@ export function TerminalSessionDropdown({ const [isOpen, setIsOpen] = useState(false); const data = context.pane.data as TerminalPaneData; const { terminalId } = data; + const sessionWorkspaceId = data.workspaceId ?? workspaceId; const terminalInstanceId = context.pane.id; + const navigate = useNavigate(); + const collections = useCollections(); const utils = workspaceTrpc.useUtils(); const killTerminalSession = workspaceTrpc.terminal.killSession.useMutation(); const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery( - { workspaceId }, + {}, { refetchInterval: isOpen ? 2_000 : false, refetchOnWindowFocus: true, }, ); + const { data: workspaceRows = [] } = useLiveQuery( + (q) => + q + .from({ v2Workspaces: collections.v2Workspaces }) + .select(({ v2Workspaces }) => ({ + id: v2Workspaces.id, + name: v2Workspaces.name, + branch: v2Workspaces.branch, + })), + [collections], + ); + + const workspaceLabels = useMemo(() => { + const labels = new Map(); + for (const row of workspaceRows) { + labels.set(row.id, row.name || row.branch || "Workspace"); + } + return labels; + }, [workspaceRows]); const sessions = useMemo(() => { const liveSessions = sessionsQuery.data?.sessions ?? []; @@ -105,7 +141,7 @@ export function TerminalSessionDropdown({ return [ { terminalId, - workspaceId, + workspaceId: sessionWorkspaceId, exited: false, exitCode: 0, attached: false, @@ -114,10 +150,48 @@ export function TerminalSessionDropdown({ }, ...liveSessions, ]; - }, [sessionsQuery.data?.sessions, terminalId, workspaceId]); + }, [sessionsQuery.data?.sessions, terminalId, sessionWorkspaceId]); const currentSession = sessions.find( (session) => session.terminalId === terminalId, ); + const sessionGroups = useMemo(() => { + const groupsByWorkspaceId = new Map(); + for (const session of sessions) { + const group = groupsByWorkspaceId.get(session.workspaceId) ?? []; + group.push(session); + groupsByWorkspaceId.set(session.workspaceId, group); + } + + return [...groupsByWorkspaceId.entries()] + .map(([groupWorkspaceId, groupSessions]) => ({ + workspaceId: groupWorkspaceId, + label: + groupWorkspaceId === workspaceId + ? "Current workspace" + : (workspaceLabels.get(groupWorkspaceId) ?? "Unknown workspace"), + sessions: [...groupSessions].sort((a, b) => { + if (a.terminalId === terminalId) return -1; + if (b.terminalId === terminalId) return 1; + return (b.createdAt ?? 0) - (a.createdAt ?? 0); + }), + })) + .sort((a, b) => { + const aHasCurrent = a.sessions.some( + (session) => session.terminalId === terminalId, + ); + const bHasCurrent = b.sessions.some( + (session) => session.terminalId === terminalId, + ); + if (aHasCurrent !== bHasCurrent) return aHasCurrent ? -1 : 1; + if (a.workspaceId === workspaceId && b.workspaceId !== workspaceId) { + return -1; + } + if (b.workspaceId === workspaceId && a.workspaceId !== workspaceId) { + return 1; + } + return a.label.localeCompare(b.label); + }); + }, [sessions, terminalId, workspaceId, workspaceLabels]); const subscribeTitle = useCallback( (callback: () => void) => terminalRuntimeRegistry.onTitleChange( @@ -136,7 +210,8 @@ export function TerminalSessionDropdown({ ? getTerminalPaneLocations(context) : EMPTY_TERMINAL_PANE_LOCATIONS; - const handleSelectSession = (nextTerminalId: string) => { + const handleSelectSession = (session: VisibleTerminalSession) => { + const nextTerminalId = session.terminalId; if (nextTerminalId === terminalId) { setIsOpen(false); return; @@ -145,18 +220,44 @@ export function TerminalSessionDropdown({ const state = context.store.getState(); const terminalPaneLocations = getTerminalPaneLocations(context); const existingLocation = terminalPaneLocations.get(nextTerminalId)?.[0]; + if (existingLocation) { + state.setActiveTab(existingLocation.tabId); + state.setActivePane({ + tabId: existingLocation.tabId, + paneId: existingLocation.paneId, + }); + setIsOpen(false); + return; + } + + if (session.attached && session.workspaceId !== workspaceId) { + void navigate({ + to: "/v2-workspace/$workspaceId", + params: { workspaceId: session.workspaceId }, + search: { + terminalId: session.terminalId, + focusRequestId: crypto.randomUUID(), + }, + }); + setIsOpen(false); + return; + } + if ((terminalPaneLocations.get(terminalId)?.length ?? 0) === 0) { markTerminalForBackground(terminalId); } state.setPaneData({ paneId: context.pane.id, - data: { terminalId: nextTerminalId } as PaneViewerData, + data: { + terminalId: nextTerminalId, + workspaceId: session.workspaceId, + } as PaneViewerData, }); state.setPaneTitleOverride({ tabId: context.tab.id, paneId: context.pane.id, - titleOverride: existingLocation?.titleOverride, + titleOverride: undefined, }); setIsOpen(false); }; @@ -175,34 +276,23 @@ export function TerminalSessionDropdown({ } }; - const removeTerminalSession = async (targetTerminalId: string) => { - await killTerminalSession.mutateAsync({ - terminalId: targetTerminalId, - workspaceId, - }); - closePanesForTerminal(targetTerminalId); - await utils.terminal.listSessions.invalidate({ workspaceId }); + const removeTerminalSession = async (session: VisibleTerminalSession) => { + try { + await killTerminalSession.mutateAsync({ + terminalId: session.terminalId, + workspaceId: session.workspaceId, + }); + closePanesForTerminal(session.terminalId); + } finally { + await utils.terminal.listSessions.invalidate(); + } }; - 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 handleRemoveTerminal = (session: VisibleTerminalSession) => { + toast.promise(removeTerminalSession(session), { + loading: "Removing terminal...", + success: "Terminal removed", + error: "Failed to remove terminal", }); }; @@ -216,6 +306,7 @@ export function TerminalSessionDropdown({ paneId: context.pane.id, data: { terminalId: crypto.randomUUID(), + workspaceId, } as PaneViewerData, }); state.setPaneTitleOverride({ @@ -223,7 +314,7 @@ export function TerminalSessionDropdown({ paneId: context.pane.id, titleOverride: undefined, }); - void utils.terminal.listSessions.invalidate({ workspaceId }); + void utils.terminal.listSessions.invalidate(); setIsOpen(false); }; @@ -255,76 +346,100 @@ export function TerminalSessionDropdown({ - - Terminal Sessions + + Terminal Sessions +
- {sessions.length > 0 ? ( - sessions.map((session) => { - const isCurrent = session.terminalId === terminalId; - const location = renderTerminalPaneLocations.get( - session.terminalId, - )?.[0]; - const createdAtLabel = formatCreatedAt(session.createdAt); - const status = isCurrent - ? "Current" - : session.pending - ? "Starting" - : session.attached - ? "Attached" - : "Detached"; - const title = isCurrent - ? triggerTitle - : (session.title ?? location?.titleOverride ?? "Terminal"); - - return ( - { - handleSelectSession(session.terminalId); - }} + {sessionGroups.length > 0 ? ( + sessionGroups.map((group, groupIndex) => ( + + {groupIndex > 0 && } +
- - {isCurrent && } + + {group.label} - - {title} + + {group.sessions.length} - - {createdAtLabel} - - - {status} - - - - ); - }) +
+ {group.sessions.map((session) => { + const isCurrent = session.terminalId === terminalId; + const location = renderTerminalPaneLocations.get( + session.terminalId, + )?.[0]; + const createdAtLabel = formatCreatedAt(session.createdAt); + const status = isCurrent + ? "Current" + : session.pending + ? "Starting" + : session.attached + ? "Attached" + : "Detached"; + const title = isCurrent + ? triggerTitle + : (session.title ?? location?.titleOverride ?? "Terminal"); + + return ( + { + handleSelectSession(session); + }} + > + + {isCurrent && } + + + {title} + + + {createdAtLabel} + + + {status} + + + + ); + })} +
+ )) ) : (
No live sessions
)}
- - - - New Terminal -
); 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 ef7e01d6d87..41b3bbb73cf 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 @@ -247,9 +247,7 @@ export function usePaneRegistry( workspaceTrpc.terminal.killSession.useMutation({ onSuccess: () => { toast.success("Terminal session killed"); - void workspaceTrpcUtils.terminal.listSessions.invalidate({ - workspaceId, - }); + void workspaceTrpcUtils.terminal.listSessions.invalidate(); }, onError: (error) => { toast.error("Failed to kill terminal session", { @@ -457,24 +455,10 @@ export function usePaneRegistry( variant: "destructive", disabled: isKillingTerminalSession, onSelect: (ctx) => { - const { terminalId } = ctx.pane.data as TerminalPaneData; - alert({ - title: "Kill terminal session?", - description: - "This will terminate the underlying process. Move the terminal to background to keep it running without a pane.", - actions: [ - { label: "Cancel", variant: "outline", onClick: () => {} }, - { - label: "Kill Session", - variant: "destructive", - onClick: () => { - killTerminalSession({ - terminalId, - workspaceId, - }); - }, - }, - ], + const data = ctx.pane.data as TerminalPaneData; + killTerminalSession({ + terminalId: data.terminalId, + workspaceId: data.workspaceId ?? workspaceId, }); }, }; 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 c201e11b0f5..d25cba104ef 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 @@ -14,6 +14,7 @@ export interface FilePaneData { export interface TerminalPaneData { terminalId: string; + workspaceId?: 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 ee39bff4278..c26d388e8ad 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 @@ -20,10 +20,23 @@ const RELEASE_DELAY_MS = 500; interface TerminalPaneData { terminalId: string; + workspaceId?: string; +} + +interface TerminalLocation { + ownerWorkspaceId: string; + sessionWorkspaceId: string; +} + +interface RemovedTerminalLocation { + terminalId: string; + ownerWorkspaceId: string; + sessionWorkspaceId: string; } interface PendingTerminalCleanup { - workspaceId: string; + ownerWorkspaceId: string; + sessionWorkspaceId: string; timer: ReturnType | null; } @@ -42,6 +55,17 @@ function getTerminalId( return typeof data.terminalId === "string" ? data.terminalId : null; } +function getTerminalSessionWorkspaceId( + pane: WorkspaceState["tabs"][number]["panes"][string], + fallbackWorkspaceId: string, +): string { + if (!pane.data || typeof pane.data !== "object") return fallbackWorkspaceId; + const data = pane.data as Partial; + return typeof data.workspaceId === "string" + ? data.workspaceId + : fallbackWorkspaceId; +} + function getTerminalInstanceKey( pane: WorkspaceState["tabs"][number]["panes"][string], ): string | null { @@ -62,8 +86,31 @@ function parseTerminalInstanceKey( function extractTerminalLocations( rows: PaneLifecycleRow[], -): Map { - return extractPaneLocations(rows, getTerminalId); +): 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 terminalId = getTerminalId(pane); + if (!terminalId) continue; + locations.set(terminalId, { + ownerWorkspaceId: row.workspaceId, + sessionWorkspaceId: getTerminalSessionWorkspaceId( + pane, + row.workspaceId, + ), + }); + } + } + } + + return locations; } function extractTerminalInstanceLocations( @@ -72,6 +119,26 @@ function extractTerminalInstanceLocations( return extractPaneLocations(rows, getTerminalInstanceKey); } +function getRemovedTerminalLocations({ + previousLocations, + currentLocations, + currentWorkspaceIds, +}: { + previousLocations: Map; + currentLocations: Map; + currentWorkspaceIds: Set; +}): RemovedTerminalLocation[] { + const removed: RemovedTerminalLocation[] = []; + + for (const [terminalId, location] of previousLocations) { + if (currentLocations.has(terminalId)) continue; + if (!currentWorkspaceIds.has(location.ownerWorkspaceId)) continue; + removed.push({ terminalId, ...location }); + } + + return removed; +} + function cleanupRemovedTerminal({ terminalId, workspaceId, @@ -109,7 +176,9 @@ function cleanupRemovedTerminal({ export function useGlobalTerminalLifecycle() { const collections = useCollections(); const { machineId, activeHostUrl } = useLocalHostService(); - const prevTerminalLocationsRef = useRef>(new Map()); + const prevTerminalLocationsRef = useRef>( + new Map(), + ); const prevTerminalInstanceLocationsRef = useRef>( new Map(), ); @@ -197,11 +266,11 @@ export function useGlobalTerminalLifecycle() { // while still cleaning up when the post-removal layout comes back. for (const [terminalId, pending] of pendingCleanups.current) { if (pending.timer) continue; - if (currentWorkspaceIds.has(pending.workspaceId)) { + if (currentWorkspaceIds.has(pending.ownerWorkspaceId)) { pendingCleanups.current.delete(terminalId); cleanupRemovedTerminal({ terminalId, - workspaceId: pending.workspaceId, + workspaceId: pending.sessionWorkspaceId, hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, }); } @@ -262,13 +331,17 @@ export function useGlobalTerminalLifecycle() { }); } - const removedLocations = getRemovedPaneLocations({ + const removedLocations = getRemovedTerminalLocations({ previousLocations: prevTerminalLocations, currentLocations: currentTerminalLocations, currentWorkspaceIds, }); - for (const { id: terminalId, workspaceId } of removedLocations) { + for (const { + terminalId, + ownerWorkspaceId, + sessionWorkspaceId, + } of removedLocations) { if (pendingCleanups.current.has(terminalId)) continue; const timer = setTimeout(() => { @@ -283,11 +356,11 @@ export function useGlobalTerminalLifecycle() { return; } - if (freshWorkspaceIds.has(workspaceId)) { + if (freshWorkspaceIds.has(ownerWorkspaceId)) { pendingCleanups.current.delete(terminalId); cleanupRemovedTerminal({ terminalId, - workspaceId, + workspaceId: sessionWorkspaceId, hostUrlByWorkspaceId: hostUrlByWorkspaceIdRef.current, }); return; @@ -299,7 +372,11 @@ export function useGlobalTerminalLifecycle() { } }, RELEASE_DELAY_MS); - pendingCleanups.current.set(terminalId, { workspaceId, timer }); + pendingCleanups.current.set(terminalId, { + ownerWorkspaceId, + sessionWorkspaceId, + timer, + }); } prevTerminalLocationsRef.current = currentTerminalLocations; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts index 5ec1d8e2cf7..886cbb358b1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/v1-terminal-cache.ts @@ -55,12 +55,45 @@ export interface CachedTerminal { subscriptionErrorHandler: ((error: unknown) => void) | null; /** ResizeObserver for the attached container. Managed by attach/detach. */ resizeObserver: ResizeObserver | null; - /** Debounce timer — fitAddon.fit() と onResize 通知を一括で発火させる */ - resizeDebounceTimer: ReturnType | null; + /** Live container, when attached. */ + container: HTMLDivElement | null; } const cache = new Map(); +function hostIsVisible(container: HTMLDivElement | null): boolean { + if (!container) return false; + return container.clientWidth > 0 && container.clientHeight > 0; +} + +function fitAndRefresh(entry: CachedTerminal): boolean { + if (!hostIsVisible(entry.container)) return false; + + const { xterm } = entry; + const buffer = xterm.buffer.active; + const wasPinnedToBottom = buffer.viewportY >= buffer.baseY; + const savedViewportY = buffer.viewportY; + const prevCols = xterm.cols; + const prevRows = xterm.rows; + + entry.fitAddon.fit(); + entry.lastCols = xterm.cols; + entry.lastRows = xterm.rows; + + if (wasPinnedToBottom) { + xterm.scrollToBottom(); + } else { + const targetY = Math.min(savedViewportY, xterm.buffer.active.baseY); + if (xterm.buffer.active.viewportY !== targetY) { + xterm.scrollToLine(targetY); + } + } + + xterm.refresh(0, Math.max(0, xterm.rows - 1)); + + return xterm.cols !== prevCols || xterm.rows !== prevRows; +} + export function has(paneId: string): boolean { return cache.has(paneId); } @@ -97,7 +130,7 @@ export function getOrCreate( eventHandler: null, subscriptionErrorHandler: null, resizeObserver: null, - resizeDebounceTimer: null, + container: null, lastCols: xterm.cols, lastRows: xterm.rows, }; @@ -116,6 +149,7 @@ export function attachToContainer( const entry = cache.get(paneId); if (!entry) return; + entry.container = container; container.appendChild(entry.wrapper); entry.openOnce(); terminalRendererDebug.info("cache-attach-to-container", { @@ -124,31 +158,12 @@ export function attachToContainer( streamReady: entry.streamReady, }); - if (container.clientWidth > 0 && container.clientHeight > 0) { - entry.fitAddon.fit(); - entry.lastCols = entry.xterm.cols; - entry.lastRows = entry.xterm.rows; - } - - // Renderer may have skipped frames while the wrapper was detached. - entry.xterm.refresh(0, Math.max(0, entry.xterm.rows - 1)); + fitAndRefresh(entry); // Manage ResizeObserver lifecycle in the cache, not in React. entry.resizeObserver?.disconnect(); - if (entry.resizeDebounceTimer) { - clearTimeout(entry.resizeDebounceTimer); - entry.resizeDebounceTimer = null; - } const observer = new ResizeObserver(() => { - if (container.clientWidth === 0 || container.clientHeight === 0) return; - - const prevCols = entry.lastCols; - const prevRows = entry.lastRows; - entry.fitAddon.fit(); - entry.lastCols = entry.xterm.cols; - entry.lastRows = entry.xterm.rows; - - if (entry.lastCols !== prevCols || entry.lastRows !== prevRows) { + if (fitAndRefresh(entry)) { onResize?.(); } }); @@ -177,6 +192,7 @@ export function detachFromContainer(paneId: string): void { ); entry.resizeObserver?.disconnect(); entry.resizeObserver = null; + entry.container = null; entry.wrapper.remove(); } @@ -195,7 +211,7 @@ export function updateAppearance( const entry = cache.get(paneId); if (!entry) return null; - const { xterm, fitAddon } = entry; + const { xterm } = entry; const fontChanged = xterm.options.fontFamily !== fontFamily || xterm.options.fontSize !== fontSize; @@ -204,16 +220,12 @@ export function updateAppearance( xterm.options.fontFamily = fontFamily; xterm.options.fontSize = fontSize; - const prevCols = entry.lastCols; - const prevRows = entry.lastRows; - fitAddon.fit(); - entry.lastCols = xterm.cols; - entry.lastRows = xterm.rows; + const changed = fitAndRefresh(entry); return { cols: xterm.cols, rows: xterm.rows, - changed: xterm.cols !== prevCols || xterm.rows !== prevRows, + changed, }; } diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index a965f7620c8..554f907f94c 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -44,7 +44,7 @@ export const terminalRouter = router({ listSessions: protectedProcedure .input( z.object({ - workspaceId: z.string(), + workspaceId: z.string().optional(), }), ) .query(({ input }) => ({ diff --git a/plans/20260425-v2-terminal-rendering-divergences.md b/plans/20260425-v2-terminal-rendering-divergences.md index b618126085d..29fcdfd7990 100644 --- a/plans/20260425-v2-terminal-rendering-divergences.md +++ b/plans/20260425-v2-terminal-rendering-divergences.md @@ -22,7 +22,7 @@ hypotheses and from items already handled by xterm internals. ## 1. Font loading race on first open -**Status:** Implemented after PR #3739. +**Status:** Partially valid. **Us:** `terminal-runtime.ts:232` — `terminal.open(wrapper)` runs immediately after construction. xterm measures cell width with whatever font is resolved at @@ -38,17 +38,14 @@ any user-selected font that is resolved through CSS font loading. Also, Tabby does not wait before `open()`; it opens first, waits a tick for font/layout settling, then configures colors/WebGL (`xtermFrontend.ts:275-306`). -**Fix:** Implemented without making `createRuntime()` async. The runtime now -stores the active resize sender, exposes WebGL atlas clearing from -`terminal-addons.ts`, and schedules a bounded font-settle refit after attach, -after font changes, and after WebGL renderer changes: -- wait for `document.fonts.load(\`${size}px ${family}\`)` when available, capped - by `FONT_SETTLE_TIMEOUT_MS`, then wait one animation frame for layout to - settle; -- clear the WebGL texture atlas when present; -- run the existing `measureAndResize(runtime)` path, which preserves viewport - state, refreshes the terminal, and sends backend resize only when cols/rows - change. +**Fix:** Do not make `createRuntime()` async unless the registry lifecycle is +rewired. Prefer a bounded post-open font-settle step: +- after `terminal.open(wrapper)`, wait for `document.fonts.ready` or + `document.fonts.load(\`${size}px ${family}\`)` when available; +- then call `measureAndResize(runtime)`, `terminal.clearTextureAtlas()` if using + the built-in API or the WebGL addon is exposed, and `terminal.refresh(...)`; +- apply the same settle/refit path after font changes at + `terminal-runtime.ts:317`. ---