Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@
// Wait for the session to be fully ready before attaching
// PTY spawn can be async and session needs to be alive for attach
const isReady = await waitForSessionReady(control, "test-session-2");
expect(isReady).toBe(true);

Check failure on line 426 in apps/desktop/src/main/terminal-host/session-lifecycle.test.ts

View workflow job for this annotation

GitHub Actions / Test

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/superset/superset/apps/desktop/src/main/terminal-host/session-lifecycle.test.ts:426:21)

// Attach to same session
const createRequest2: IpcRequest = {
Expand Down Expand Up @@ -686,6 +686,11 @@

await sendRequest(control, createRequest);

const isReady = await waitForSessionReady(control, "test-session-kill");
expect(isReady).toBe(true);

const exitPromise = waitForEvent(stream, "exit", 5000);

// Kill session
const killRequest: IpcRequest = {
id: "test-kill-2",
Expand All @@ -699,7 +704,7 @@
expect(killResponse.ok).toBe(true);

// Wait for exit event
const exitEvent = await waitForEvent(stream, "exit", 5000);
const exitEvent = await exitPromise;
expect(exitEvent.sessionId).toBe("test-session-kill");
} finally {
control.destroy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@ export function DiffViewer({
const [isEditorMounted, setIsEditorMounted] = useState(false);
const hasScrolledToFirstDiffRef = useRef(false);

useEffect(() => {
if (!isMonacoReady) return;
if (!isEditorMounted) return;

requestAnimationFrame(() => {
const modifiedEditor = modifiedEditorRef.current;
if (modifiedEditor) {
modifiedEditor.layout();
}
});
}, [isMonacoReady, isEditorMounted]);

// biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only
useEffect(() => {
hasScrolledToFirstDiffRef.current = false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ interface TabPaneProps {
paneId: string;
path: MosaicBranch[];
isActive: boolean;
isTabVisible: boolean;
tabId: string;
workspaceId: string;
splitPaneAuto: (
Expand Down Expand Up @@ -46,7 +45,6 @@ export function TabPane({
paneId,
path,
isActive,
isTabVisible,
tabId,
workspaceId,
splitPaneAuto,
Expand Down Expand Up @@ -125,11 +123,7 @@ export function TabPane({
onMoveToNewTab={onMoveToNewTab}
>
<div ref={terminalContainerRef} className="w-full h-full">
<Terminal
tabId={paneId}
workspaceId={workspaceId}
isTabVisible={isTabVisible}
/>
<Terminal tabId={paneId} workspaceId={workspaceId} />
</div>
</TabContentContextMenu>
</BasePaneWindow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ import { TabPane } from "./TabPane";

interface TabViewProps {
tab: Tab;
isTabVisible?: boolean;
}

export function TabView({ tab, isTabVisible = true }: TabViewProps) {
export function TabView({ tab }: TabViewProps) {
const updateTabLayout = useTabsStore((s) => s.updateTabLayout);
const removePane = useTabsStore((s) => s.removePane);
const removeTab = useTabsStore((s) => s.removeTab);
Expand Down Expand Up @@ -149,7 +148,6 @@ export function TabView({ tab, isTabVisible = true }: TabViewProps) {
paneId={paneId}
path={path}
isActive={isActive}
isTabVisible={isTabVisible}
tabId={tab.id}
workspaceId={tab.workspaceId}
splitPaneAuto={splitPaneAuto}
Expand All @@ -166,7 +164,6 @@ export function TabView({ tab, isTabVisible = true }: TabViewProps) {
[
tabPanes,
focusedPaneId,
isTabVisible,
tab.id,
tab.workspaceId,
worktreePath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,7 @@ type CreateOrAttachResult = {
};
};

export const Terminal = ({
tabId,
workspaceId,
isTabVisible,
}: TerminalProps) => {
export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
const paneId = tabId;
// Use granular selectors to avoid re-renders when other panes change
const pane = useTabsStore((s) => s.panes[paneId]);
Expand Down Expand Up @@ -160,8 +156,6 @@ export const Terminal = ({
const initialThemeRef = useRef(terminalTheme);

const isFocused = focusedPaneId === paneId;
const isTabVisibleRef = useRef(isTabVisible);
isTabVisibleRef.current = isTabVisible;

// Gate streaming until initial state restoration is applied to avoid interleaving output.
const isStreamReadyRef = useRef(false);
Expand Down Expand Up @@ -972,16 +966,13 @@ export const Terminal = ({
}, [isFocused]);

useEffect(() => {
if (isFocused && isTabVisible && xtermRef.current) {
xtermRef.current.focus();
}
}, [isFocused, isTabVisible]);
const xterm = xtermRef.current;
if (!xterm) return;

useEffect(() => {
if (!isTabVisible && xtermRef.current) {
xtermRef.current.blur();
if (isFocused) {
xterm.focus();
}
}, [isTabVisible]);
}, [isFocused]);

useAppHotkey(
"FIND_IN_TERMINAL",
Expand Down Expand Up @@ -1124,9 +1115,6 @@ export const Terminal = ({
if (isRestoredModeRef.current || connectionErrorRef.current) {
return;
}
if (!isTabVisibleRef.current) {
return;
}
if (isExitedRef.current) {
if (!isFocusedRef.current || wasKilledByUserRef.current) {
return;
Expand All @@ -1145,9 +1133,6 @@ export const Terminal = ({
if (isRestoredModeRef.current || connectionErrorRef.current) {
return;
}
if (!isTabVisibleRef.current) {
return;
}
const { domEvent } = event;
if (domEvent.key === "Enter") {
// Don't auto-title from keyboard when in alternate screen (TUI apps like vim, codex)
Expand Down Expand Up @@ -1197,7 +1182,7 @@ export const Terminal = ({

const cancelInitialAttach = scheduleTerminalAttach({
paneId,
priority: isTabVisible ? (isFocusedRef.current ? 0 : 1) : 2,
priority: isFocusedRef.current ? 0 : 1,
run: (done) => {
if (isTerminalKilledByUser(paneId)) {
wasKilledByUserRef.current = true;
Expand Down Expand Up @@ -1338,7 +1323,7 @@ export const Terminal = ({
};

const handleWrite = (data: string) => {
if (!isTabVisibleRef.current || isExitedRef.current) {
if (isExitedRef.current) {
return;
}
writeRef.current({ paneId, data });
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export interface TerminalProps {
tabId: string;
workspaceId: string;
isTabVisible: boolean;
}

export type TerminalStreamEvent =
Expand Down
Original file line number Diff line number Diff line change
@@ -1,40 +1,21 @@
import { useParams } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { electronTrpc } from "renderer/lib/electron-trpc";
import { useMemo } from "react";
import { useSidebarStore } from "renderer/stores";
import {
MAX_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
} from "renderer/stores/sidebar-state";
import { useTabsStore } from "renderer/stores/tabs/store";
import type { Pane, Tab } from "renderer/stores/tabs/types";
import {
extractPaneIdsFromLayout,
resolveActiveTabIdForWorkspace,
} from "renderer/stores/tabs/utils";
import { resolveActiveTabIdForWorkspace } from "renderer/stores/tabs/utils";
import { ResizablePanel } from "../../../ResizablePanel";
import { Sidebar } from "../../Sidebar";
import { EmptyTabView } from "./EmptyTabView";
import { TabView } from "./TabView";

const WARM_TERMINAL_TAB_LIMIT = 8;

/**
* Check if a tab contains at least one terminal pane.
* Used to determine which tabs need to stay mounted for persistence.
*/
function hasTerminalPane(tab: Tab, panes: Record<string, Pane>): boolean {
const paneIds = extractPaneIdsFromLayout(tab.layout);
return paneIds.some((paneId) => panes[paneId]?.type === "terminal");
}

export function TabsContent() {
const { workspaceId: activeWorkspaceId } = useParams({ strict: false });
const { data: terminalPersistence } =
electronTrpc.settings.getTerminalPersistence.useQuery();
const allTabs = useTabsStore((s) => s.tabs);
const activeTabIds = useTabsStore((s) => s.activeTabIds);
const panes = useTabsStore((s) => s.panes);
const tabHistoryStacks = useTabsStore((s) => s.tabHistoryStacks);

const {
Expand Down Expand Up @@ -66,118 +47,11 @@ export function TabsContent() {
return allTabs.find((tab) => tab.id === activeTabId) || null;
}, [activeTabId, allTabs]);

const activeTabHasTerminal = useMemo(() => {
if (!tabToRender) return false;
return hasTerminalPane(tabToRender, panes);
}, [tabToRender, panes]);

// Per-run warm set of terminal tab IDs (MRU order). Not persisted.
const [warmTerminalTabIds, setWarmTerminalTabIds] = useState<string[]>([]);

// Track terminal tab visits to keep a bounded set mounted for smooth switching.
useEffect(() => {
if (!terminalPersistence) return;
if (!activeTabId) return;
if (!activeTabHasTerminal) return;

setWarmTerminalTabIds((prev) => {
const next = [activeTabId, ...prev.filter((id) => id !== activeTabId)];
return next.slice(0, WARM_TERMINAL_TAB_LIMIT);
});
}, [terminalPersistence, activeTabId, activeTabHasTerminal]);

// When terminal persistence is enabled, keep a bounded set of terminal tabs
// mounted across workspace/tab switches. This prevents TUI white screen issues
// for recently used terminals by avoiding the unmount/remount cycle that
// requires complex reattach/rehydration, while avoiding startup fan-out.
// Non-terminal tabs use normal unmount behavior to save memory.
// Uses visibility:hidden (not display:none) to preserve xterm dimensions.
if (terminalPersistence) {
// Partition tabs: a bounded set of terminal tabs stay mounted, non-terminal tabs unmount when inactive.
const terminalTabs = allTabs.filter((tab) => hasTerminalPane(tab, panes));
const terminalTabsById = new Map(terminalTabs.map((tab) => [tab.id, tab]));

const warmIdsFiltered = warmTerminalTabIds.filter((id) =>
terminalTabsById.has(id),
);

// Ensure active terminal tab is included in the mounted set even before the
// warm-set effect runs (first render after tab switch).
const terminalTabIdsToRender = (() => {
const ids = [...warmIdsFiltered];
if (activeTabHasTerminal && activeTabId && !ids.includes(activeTabId)) {
ids.unshift(activeTabId);
}
return ids.slice(0, WARM_TERMINAL_TAB_LIMIT);
})();

const terminalTabsToRender = terminalTabIdsToRender
.map((id) => terminalTabsById.get(id))
.filter((tab): tab is Tab => !!tab);

const activeNonTerminalTab =
tabToRender && !activeTabHasTerminal ? tabToRender : null;

return (
<div className="flex-1 min-h-0 flex overflow-hidden">
<div className="relative flex-1 min-w-0">
{/* Terminal tabs: keep mounted with visibility toggle */}
{terminalTabsToRender.map((tab) => {
const isVisible =
tab.workspaceId === activeWorkspaceId && tab.id === activeTabId;

return (
<div
key={tab.id}
className="absolute inset-0"
style={{
visibility: isVisible ? "visible" : "hidden",
pointerEvents: isVisible ? "auto" : "none",
}}
>
<TabView tab={tab} isTabVisible={isVisible} />
</div>
);
})}
{/* Active non-terminal tab: render normally (unmounts when switching) */}
{activeNonTerminalTab && (
<div className="absolute inset-0">
<TabView tab={activeNonTerminalTab} isTabVisible />
</div>
)}
{/* Fallback: show empty view without unmounting terminal tabs */}
{!activeNonTerminalTab && !tabToRender && (
<div className="absolute inset-0 overflow-hidden">
<EmptyTabView />
</div>
)}
</div>
{isSidebarOpen && (
<ResizablePanel
width={sidebarWidth}
onWidthChange={setSidebarWidth}
isResizing={isResizing}
onResizingChange={setIsResizing}
minWidth={MIN_SIDEBAR_WIDTH}
maxWidth={MAX_SIDEBAR_WIDTH}
handleSide="left"
>
<Sidebar />
</ResizablePanel>
)}
</div>
);
}

// Original behavior when persistence disabled: only render active tab
return (
<div className="flex-1 min-h-0 flex overflow-hidden">
<div className="flex-1 min-w-0 overflow-hidden">
{tabToRender ? (
<TabView tab={tabToRender} isTabVisible />
) : (
<EmptyTabView />
)}
{tabToRender ? <TabView tab={tabToRender} /> : <EmptyTabView />}
</div>
{isSidebarOpen && (
<ResizablePanel
Expand Down
Loading