diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx
index f8034f223..bed3dc1b3 100644
--- a/packages/app/src/components/split-container.tsx
+++ b/packages/app/src/components/split-container.tsx
@@ -1,5 +1,6 @@
import {
Fragment,
+ memo,
useCallback,
useEffect,
useMemo,
@@ -43,6 +44,7 @@ import {
deriveWorkspacePaneState,
getWorkspacePaneDescriptors,
} from "@/screens/workspace/workspace-pane-state";
+import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
@@ -63,6 +65,7 @@ import {
type WorkspaceLayout,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
+import { workspaceTabTargetsEqual } from "@/utils/workspace-tab-identity";
interface SplitContainerProps {
layout: WorkspaceLayout;
@@ -150,6 +153,68 @@ interface SplitPaneViewProps
tabDropPreview: TabDropPreview | null;
}
+interface MountedTabSlotProps {
+ tabDescriptor: WorkspaceTabDescriptor;
+ isVisible: boolean;
+ isPaneFocused: boolean;
+ paneId: string;
+ buildPaneContentModel: (input: {
+ paneId: string;
+ isPaneFocused: boolean;
+ tab: WorkspaceTabDescriptor;
+ }) => WorkspacePaneContentModel;
+}
+
+const MountedTabSlot = memo(function MountedTabSlot({
+ tabDescriptor,
+ isVisible,
+ isPaneFocused,
+ paneId,
+ buildPaneContentModel,
+}: MountedTabSlotProps) {
+ const content = useMemo(
+ () =>
+ buildPaneContentModel({
+ paneId,
+ isPaneFocused,
+ tab: tabDescriptor,
+ }),
+ [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
+ );
+
+ return (
+
+
+
+ );
+});
+
+function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
+ const cacheRef = useRef(new Map());
+ const tabDescriptorMap = useMemo(() => {
+ const next = new Map();
+ for (const tabDescriptor of tabDescriptors) {
+ const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
+ if (
+ cachedDescriptor &&
+ cachedDescriptor.key === tabDescriptor.key &&
+ cachedDescriptor.kind === tabDescriptor.kind &&
+ workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
+ ) {
+ next.set(tabDescriptor.tabId, cachedDescriptor);
+ continue;
+ }
+ next.set(tabDescriptor.tabId, tabDescriptor);
+ }
+ return next;
+ }, [tabDescriptors]);
+ useEffect(() => {
+ cacheRef.current = tabDescriptorMap;
+ }, [tabDescriptorMap]);
+
+ return tabDescriptorMap;
+}
+
const dropCollisionDetection: CollisionDetection = (args) => {
const pointerHits = pointerWithin(args);
const tabHits = pointerHits.filter(
@@ -725,7 +790,18 @@ function SplitPaneView({
[pane, uiTabs],
);
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
+ const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
+ const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
+ const { mountedTabIds } = useMountedTabSet({
+ activeTabId: activeTabDescriptor?.tabId ?? null,
+ allTabIds: paneTabIds,
+ cap: 3,
+ });
+ const mountedPaneTabIds = useMemo(
+ () => paneTabIds.filter((tabId) => mountedTabIds.has(tabId)),
+ [mountedTabIds, paneTabIds],
+ );
const desktopTabRowItems = useMemo(
() =>
paneTabs.map((tab) => ({
@@ -736,17 +812,6 @@ function SplitPaneView({
})),
[activeTabDescriptor?.key, closingTabIds, hoveredCloseTabKey, paneTabs],
);
- const paneContent = useMemo(
- () =>
- activeTabDescriptor
- ? buildPaneContentModel({
- paneId: pane.id,
- isPaneFocused: isFocused,
- tab: activeTabDescriptor,
- })
- : null,
- [activeTabDescriptor, buildPaneContentModel, isFocused, pane.id],
- );
useEffect(() => {
if (Platform.OS !== "web") {
@@ -823,8 +888,24 @@ function SplitPaneView({
- {paneContent ? (
-
+ {mountedPaneTabIds.length > 0 ? (
+ mountedPaneTabIds.map((tabId) => {
+ const tabDescriptor = tabDescriptorMap.get(tabId);
+ if (!tabDescriptor) {
+ return null;
+ }
+
+ return (
+
+ );
+ })
) : (
(renderPaneEmptyState?.() ?? null)
)}
diff --git a/packages/app/src/screens/workspace/use-mounted-tab-set.ts b/packages/app/src/screens/workspace/use-mounted-tab-set.ts
new file mode 100644
index 000000000..35eeaddbb
--- /dev/null
+++ b/packages/app/src/screens/workspace/use-mounted-tab-set.ts
@@ -0,0 +1,64 @@
+import { useLayoutEffect, useMemo, useRef, useState } from "react";
+
+interface UseMountedTabSetInput {
+ activeTabId: string | null;
+ allTabIds: string[];
+ cap: number;
+}
+
+interface UseMountedTabSetResult {
+ mountedTabIds: Set;
+}
+
+function createInitialMountedTabIds(input: UseMountedTabSetInput): Set {
+ if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
+ return new Set();
+ }
+ return new Set([input.activeTabId]);
+}
+
+function setsEqual(left: Set, right: Set): boolean {
+ if (left.size !== right.size) {
+ return false;
+ }
+ for (const value of left) {
+ if (!right.has(value)) {
+ return false;
+ }
+ }
+ return true;
+}
+
+export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult {
+ const { activeTabId, allTabIds, cap } = input;
+ const allTabIdsKey = allTabIds.join("\u0000");
+ const availableTabIds = useMemo(() => new Set(allTabIds), [allTabIdsKey]);
+ const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input));
+ const lruRef = useRef(
+ activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : [],
+ );
+
+ useLayoutEffect(() => {
+ const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId));
+ if (activeTabId && availableTabIds.has(activeTabId)) {
+ const existingIndex = nextLru.indexOf(activeTabId);
+ if (existingIndex >= 0) {
+ nextLru.splice(existingIndex, 1);
+ }
+ nextLru.unshift(activeTabId);
+ }
+ if (nextLru.length > cap) {
+ nextLru.length = cap;
+ }
+
+ lruRef.current = nextLru;
+ setMountedTabIds((previousMountedTabIds) => {
+ const nextMountedTabIds = new Set(nextLru);
+ return setsEqual(previousMountedTabIds, nextMountedTabIds)
+ ? previousMountedTabIds
+ : nextMountedTabIds;
+ });
+ }, [activeTabId, availableTabIds, cap]);
+
+ return { mountedTabIds };
+}
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index afd8ea21a..9b547b23a 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -97,7 +97,9 @@ import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-sta
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
+ type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
+import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
import {
buildBulkCloseConfirmationMessage,
classifyBulkClosableTabs,
@@ -438,6 +440,68 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
);
});
+interface MobileMountedTabSlotProps {
+ tabDescriptor: WorkspaceTabDescriptor;
+ isVisible: boolean;
+ isPaneFocused: boolean;
+ paneId: string | null;
+ buildPaneContentModel: (input: {
+ paneId: string | null;
+ isPaneFocused: boolean;
+ tab: WorkspaceTabDescriptor;
+ }) => WorkspacePaneContentModel;
+}
+
+const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
+ tabDescriptor,
+ isVisible,
+ isPaneFocused,
+ paneId,
+ buildPaneContentModel,
+}: MobileMountedTabSlotProps) {
+ const content = useMemo(
+ () =>
+ buildPaneContentModel({
+ paneId,
+ isPaneFocused,
+ tab: tabDescriptor,
+ }),
+ [buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
+ );
+
+ return (
+
+
+
+ );
+});
+
+function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
+ const cacheRef = useRef(new Map());
+ const tabDescriptorMap = useMemo(() => {
+ const next = new Map();
+ for (const tabDescriptor of tabDescriptors) {
+ const cachedDescriptor = cacheRef.current.get(tabDescriptor.tabId);
+ if (
+ cachedDescriptor &&
+ cachedDescriptor.key === tabDescriptor.key &&
+ cachedDescriptor.kind === tabDescriptor.kind &&
+ workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
+ ) {
+ next.set(tabDescriptor.tabId, cachedDescriptor);
+ continue;
+ }
+ next.set(tabDescriptor.tabId, tabDescriptor);
+ }
+ return next;
+ }, [tabDescriptors]);
+ useEffect(() => {
+ cacheRef.current = tabDescriptorMap;
+ }, [tabDescriptorMap]);
+
+ return tabDescriptorMap;
+}
+
export function WorkspaceScreen({ serverId, workspaceId }: WorkspaceScreenProps) {
const isFocused = useIsFocused();
@@ -1726,37 +1790,33 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
retargetWorkspaceTab,
};
});
- const activePaneContent = useMemo(
- () =>
- activeTabDescriptor
- ? buildPaneContentModel({
- tab: activeTabDescriptor,
- paneId: focusedPaneTabState.pane?.id ?? null,
- isPaneFocused: true,
- focusPaneBeforeOpen: false,
- })
- : null,
- [activeTabDescriptor, buildPaneContentModel, focusedPaneTabState.pane?.id],
+ const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
+ const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
+ const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
+ const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
+ activeTabId: activeTabDescriptor?.tabId ?? null,
+ allTabIds: focusedPaneTabIds,
+ cap: 3,
+ });
+ const mountedFocusedPaneTabIds = useMemo(
+ () => focusedPaneTabIds.filter((tabId) => mountedFocusedPaneTabIdsSet.has(tabId)),
+ [focusedPaneTabIds, mountedFocusedPaneTabIdsSet],
+ );
+ const buildMobilePaneContentModel = useCallback(
+ function buildMobilePaneContentModel(input: {
+ paneId: string | null;
+ tab: WorkspaceTabDescriptor;
+ isPaneFocused: boolean;
+ }) {
+ return buildPaneContentModel({
+ tab: input.tab,
+ paneId: input.paneId,
+ isPaneFocused: input.isPaneFocused,
+ focusPaneBeforeOpen: false,
+ });
+ },
+ [buildPaneContentModel],
);
- const prevActivePaneDeps = useRef({
- activeTabDescriptor,
- buildPaneContentModel,
- paneId: focusedPaneTabState.pane?.id,
- });
- useEffect(() => {
- const prev = prevActivePaneDeps.current;
- if (prev.activeTabDescriptor !== activeTabDescriptor)
- console.log("[activePaneContent] activeTabDescriptor changed");
- if (prev.buildPaneContentModel !== buildPaneContentModel)
- console.log("[activePaneContent] buildPaneContentModel changed");
- if (prev.paneId !== focusedPaneTabState.pane?.id)
- console.log("[activePaneContent] paneId changed");
- prevActivePaneDeps.current = {
- activeTabDescriptor,
- buildPaneContentModel,
- paneId: focusedPaneTabState.pane?.id,
- };
- });
const content = shouldRenderMissingWorkspaceDescriptor({
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
@@ -1777,7 +1837,23 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
)
) : (
-
+ mountedFocusedPaneTabIds.map((tabId) => {
+ const tabDescriptor = focusedPaneTabDescriptorMap.get(tabId);
+ if (!tabDescriptor) {
+ return null;
+ }
+
+ return (
+
+ );
+ })
);
const buildDesktopPaneContentModel = useCallback(
diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
index 368fa4c9c..f25c420c5 100644
--- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
+++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts
@@ -1,5 +1,10 @@
+import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
+import { ImageAddon } from "@xterm/addon-image";
+import { LigaturesAddon } from "@xterm/addon-ligatures";
+import { SearchAddon } from "@xterm/addon-search";
import { Unicode11Addon } from "@xterm/addon-unicode11";
+import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal, type ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
@@ -149,6 +154,9 @@ export class TerminalEmulatorRuntime {
fontFamily: DEFAULT_TERMINAL_FONT_FAMILY,
fontSize: 13,
lineHeight: 1.0,
+ macOptionIsMeta: true,
+ minimumContrastRatio: 1,
+ rescaleOverlappingGlyphs: true,
overviewRuler: {
width: 8,
},
@@ -160,6 +168,14 @@ export class TerminalEmulatorRuntime {
let webglAddon: WebglAddon | null = null;
terminal.loadAddon(fitAddon);
terminal.loadAddon(unicode11Addon);
+ terminal.loadAddon(new WebLinksAddon());
+ terminal.loadAddon(new SearchAddon({ highlightLimit: 20_000 }));
+ terminal.loadAddon(new ClipboardAddon());
+ try {
+ terminal.loadAddon(new LigaturesAddon());
+ } catch {
+ // Ligatures require Font Access API or compatible environment
+ }
terminal.open(input.host);
try {
terminal.unicode.activeVersion = "11";
@@ -193,6 +209,8 @@ export class TerminalEmulatorRuntime {
}
});
+ terminal.loadAddon(new ImageAddon());
+
const restoreDocumentStyles = this.applyDocumentBoundsStyles({
root: input.root,
});
@@ -211,6 +229,10 @@ export class TerminalEmulatorRuntime {
return;
}
+ if (input.root.offsetWidth === 0 || input.root.offsetHeight === 0) {
+ return;
+ }
+
try {
currentFitAddon.fit();
} catch {
@@ -657,7 +679,6 @@ export class TerminalEmulatorRuntime {
private applyViewportTouchStyles(input: { host: HTMLDivElement }): () => void {
const viewportElement = input.host.querySelector(".xterm-viewport");
- const screenElement = input.host.querySelector(".xterm-screen");
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
@@ -666,8 +687,6 @@ export class TerminalEmulatorRuntime {
const previousViewportPointerEvents = viewportElement?.style.pointerEvents ?? "";
const previousViewportWebkitOverflowScrolling =
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
- const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? "";
-
if (viewportElement) {
viewportElement.style.overscrollBehavior = "none";
viewportElement.style.touchAction = "pan-y";
@@ -676,9 +695,6 @@ export class TerminalEmulatorRuntime {
viewportElement.style.pointerEvents = "auto";
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
}
- if (screenElement) {
- screenElement.style.pointerEvents = "none";
- }
return () => {
if (viewportElement) {
@@ -692,9 +708,6 @@ export class TerminalEmulatorRuntime {
previousViewportWebkitOverflowScrolling,
);
}
- if (screenElement) {
- screenElement.style.pointerEvents = previousScreenPointerEvents;
- }
};
}