Make workspace tab switching faster (#1251)

* Avoid workspace shell renders when toggling sidebar

* Avoid rerendering inactive workspace screens

* Avoid rerendering unchanged sidebar rows

* Keep sidebar selection state in the list

* Speed up workspace tab switching
This commit is contained in:
Mohamed Boudra
2026-05-31 15:46:17 +08:00
committed by GitHub
parent 67133e3ebc
commit f26a81798b
12 changed files with 909 additions and 323 deletions
@@ -62,6 +62,7 @@ import {
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import type { Theme } from "@/styles/theme";
import { RenderProfile } from "@/utils/render-profiler";
const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
@@ -148,7 +149,6 @@ interface WorkspaceDesktopTabsRowProps {
tabs: WorkspaceDesktopTabRowItem[];
normalizedServerId: string;
normalizedWorkspaceId: string;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -255,7 +255,6 @@ function TabChip({
presentation,
tooltipLabel,
resolvedTab,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -273,7 +272,6 @@ function TabChip({
presentation: WorkspaceTabPresentation;
tooltipLabel: string;
resolvedTab: WorkspaceDesktopTabActions;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -311,13 +309,11 @@ function TabChip({
const handleTabHoverIn = useCallback(() => {
setHovered(true);
setHoveredTabKey(tab.key);
}, [setHoveredTabKey, tab.key]);
}, []);
const handleTabHoverOut = useCallback(() => {
setHovered(false);
setHoveredTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, tab.key]);
}, []);
const handleNavigateTab = useCallback(() => {
onNavigateTab(tab.tabId);
@@ -328,14 +324,12 @@ function TabChip({
}, []);
const handleCloseButtonHoverIn = useCallback(() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key);
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
}, [setHoveredCloseTabKey, tab.key]);
const handleCloseButtonHoverOut = useCallback(() => {
setHoveredTabKey((current) => (current === tab.key ? null : current));
setHoveredCloseTabKey((current) => (current === tab.key ? null : current));
}, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]);
}, [setHoveredCloseTabKey, tab.key]);
const handleCloseButtonPress = useCallback(
(event: { stopPropagation?: () => void }) => {
@@ -466,7 +460,6 @@ export function WorkspaceDesktopTabsRow({
tabs,
normalizedServerId,
normalizedWorkspaceId,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -613,7 +606,6 @@ export function WorkspaceDesktopTabsRow({
resolvedTabWidth={resolvedTabWidth}
showLabel={showLabel}
showCloseButton={shouldShowCloseButton}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -640,7 +632,6 @@ export function WorkspaceDesktopTabsRow({
onReloadAgent,
onRenameTab,
setHoveredCloseTabKey,
setHoveredTabKey,
tabDropPreviewIndex,
tabs.length,
],
@@ -656,7 +647,7 @@ export function WorkspaceDesktopTabsRow({
[layout.requiresHorizontalScrollFallback],
);
return (
const row = (
<View
style={styles.tabsContainer}
testID="workspace-tabs-row"
@@ -787,8 +778,9 @@ export function WorkspaceDesktopTabsRow({
</View>
</View>
);
}
return <RenderProfile id="WorkspaceDesktopTabsRow">{row}</RenderProfile>;
}
function ResolvedDesktopTabChip({
item,
isFocused,
@@ -807,7 +799,6 @@ function ResolvedDesktopTabChip({
resolvedTabWidth,
showLabel,
showCloseButton,
setHoveredTabKey,
setHoveredCloseTabKey,
onNavigateTab,
onCloseTab,
@@ -832,7 +823,6 @@ function ResolvedDesktopTabChip({
resolvedTabWidth: number;
showLabel: boolean;
showCloseButton: boolean;
setHoveredTabKey: Dispatch<SetStateAction<string | null>>;
setHoveredCloseTabKey: Dispatch<SetStateAction<string | null>>;
onNavigateTab: (tabId: string) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
@@ -896,7 +886,6 @@ function ResolvedDesktopTabChip({
presentation={presentation}
tooltipLabel={tooltipLabel}
resolvedTab={resolvedTab}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={onNavigateTab}
onCloseTab={onCloseTab}
@@ -6,9 +6,11 @@ import {
PaneProvider,
type PaneContextValue,
} from "@/panels/pane-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { RenderProfile } from "@/utils/render-profiler";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
export interface WorkspacePaneContentModel {
@@ -72,6 +74,35 @@ export function WorkspacePaneContent({
onFocusPane,
}: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
const openTab = useStableEvent(paneContextValue.openTab);
const closeCurrentTab = useStableEvent(paneContextValue.closeCurrentTab);
const retargetCurrentTab = useStableEvent(paneContextValue.retargetCurrentTab);
const openFileInWorkspace = useStableEvent(paneContextValue.openFileInWorkspace);
const openImportSheet = useStableEvent(paneContextValue.openImportSheet);
const stablePaneContextValue = useMemo(
() => ({
serverId: paneContextValue.serverId,
workspaceId: paneContextValue.workspaceId,
tabId: paneContextValue.tabId,
target: paneContextValue.target,
openTab,
closeCurrentTab,
retargetCurrentTab,
openFileInWorkspace,
openImportSheet,
}),
[
closeCurrentTab,
openFileInWorkspace,
openImportSheet,
openTab,
paneContextValue.serverId,
paneContextValue.tabId,
paneContextValue.target,
paneContextValue.workspaceId,
retargetCurrentTab,
],
);
const paneFocusValue = useMemo(
() =>
createPaneFocusContextValue({
@@ -83,10 +114,14 @@ export function WorkspacePaneContent({
);
return (
<PaneProvider value={paneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
<RenderProfile
id={`WorkspacePaneContent:${paneContextValue.target.kind}:${paneContextValue.tabId}`}
>
<PaneProvider value={stablePaneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
</RenderProfile>
);
}
@@ -177,6 +177,7 @@ import {
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
import { RenderProfile } from "@/utils/render-profiler";
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels";
@@ -734,16 +735,43 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
: styles.mobileMountedTabSlotHidden;
return (
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</RenderProfile>
);
});
interface MobileExplorerOpenGestureSurfaceProps {
children: ReactNode;
onOpenExplorer: () => void;
}
function MobileExplorerOpenGestureSurface({
children,
onOpenExplorer,
}: MobileExplorerOpenGestureSurfaceProps) {
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: canOpenExplorerFromAgentView,
onOpen: onOpenExplorer,
});
return (
<GestureDetector gesture={explorerOpenGesture} touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}>
<View style={styles.content}>{children}</View>
</GestureDetector>
);
}
function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
const cacheRef = useRef(new Map<string, WorkspaceTabDescriptor>());
const tabDescriptorMap = useMemo(() => {
@@ -770,7 +798,11 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
return tabDescriptorMap;
}
export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: WorkspaceScreenProps) {
export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId,
workspaceId,
isRouteFocused,
}: WorkspaceScreenProps) {
const navigationFocused = useIsFocused();
const effectiveRouteFocused = isRouteFocused ?? navigationFocused;
@@ -783,7 +815,7 @@ export function WorkspaceScreen({ serverId, workspaceId, isRouteFocused }: Works
/>
</ExplorerSidebarAnimationProvider>
);
}
});
interface UseCloseTabsResult {
closingTabIds: Set<string>;
@@ -1657,10 +1689,6 @@ function WorkspaceScreenContent({
const isExplorerOpen = usePanelStore((state) =>
selectIsFileExplorerOpen(state, { isCompact: isMobile }),
);
const canOpenExplorerFromAgentView = usePanelStore(
(state) =>
state.mobileView === "agent" && !selectIsFileExplorerOpen(state, { isCompact: true }),
);
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
const toggleFileExplorerForCheckout = usePanelStore(
(state) => state.toggleFileExplorerForCheckout,
@@ -1712,11 +1740,6 @@ function WorkspaceScreenContent({
[isExplorerOpen],
);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && canOpenExplorerFromAgentView,
onOpen: openExplorerForWorkspace,
});
useEffect(() => {
if (!isRouteFocused || isWeb || !isExplorerOpen) {
return;
@@ -2178,7 +2201,31 @@ function WorkspaceScreenContent({
],
);
const [_hoveredTabKey, setHoveredTabKey] = useState<string | null>(null);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
request,
paneId,
parentTabId,
focusPaneBeforeOpen,
}: {
request: WorkspaceFileOpenRequest;
paneId?: string | null;
parentTabId: string;
focusPaneBeforeOpen?: boolean;
}) {
if (focusPaneBeforeOpen && paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: paneId ?? undefined,
parentTabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId });
});
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
@@ -2300,7 +2347,6 @@ function WorkspaceScreenContent({
}
removeTerminalFromCache(terminalId);
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2349,7 +2395,6 @@ function WorkspaceScreenContent({
}
}
setHoveredTabKey((current) => (current === tabId ? null : current));
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2374,7 +2419,6 @@ function WorkspaceScreenContent({
tabId: string;
target?: WorkspaceTabTarget | null;
}) {
setHoveredTabKey((current) => (current === input.tabId ? null : current));
setHoveredCloseTabKey((current) => (current === input.tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({ tabId: input.tabId, target: input.target });
@@ -2556,7 +2600,6 @@ function WorkspaceScreenContent({
});
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[client, closeTab, closeWorkspaceTabWithCleanup, persistenceKey],
@@ -2851,26 +2894,19 @@ function WorkspaceScreenContent({
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
},
onOpenWorkspaceFile: (request: WorkspaceFileOpenRequest) => {
if (input.focusPaneBeforeOpen && input.paneId && persistenceKey) {
focusWorkspacePane(persistenceKey, input.paneId);
}
if (request.disposition === "side") {
handleOpenFileFromChatInSidePane({
location: request.location,
sourcePaneId: input.paneId ?? undefined,
parentTabId: input.tab.tabId,
});
return;
}
handleOpenFileFromChat(request.location, { parentTabId: input.tab.tabId });
handleOpenWorkspaceFileFromPane({
request,
paneId: input.paneId,
parentTabId: input.tab.tabId,
focusPaneBeforeOpen: input.focusPaneBeforeOpen,
});
},
onOpenImportSheet: openImportSheet,
}),
[
handleCloseTabById,
handleOpenFileFromChat,
handleOpenFileFromChatInSidePane,
focusWorkspacePane,
handleOpenWorkspaceFileFromPane,
navigateToTabId,
normalizedServerId,
normalizedWorkspaceId,
@@ -3193,9 +3229,9 @@ function WorkspaceScreenContent({
`${WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX}:${normalizedServerId}:${normalizedWorkspaceId}`,
[normalizedServerId, normalizedWorkspaceId],
);
const desktopContent = useMemo(() => {
const desktopSplitContent = useMemo(() => {
if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) {
return content;
return null;
}
return (
<SplitContainer
@@ -3207,7 +3243,6 @@ function WorkspaceScreenContent({
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
closingTabIds={closingTabIds}
onNavigateTab={navigateToTabId}
@@ -3234,7 +3269,6 @@ function WorkspaceScreenContent({
/>
);
}, [
content,
canRenderDesktopPaneSplits,
workspaceLayout,
persistenceKey,
@@ -3267,6 +3301,7 @@ function WorkspaceScreenContent({
handleReorderTabsInPane,
renderSplitPaneEmptyState,
]);
const desktopContent = desktopSplitContent ?? content;
const workspaceCenterColumn = (
<View style={styles.centerColumn}>
@@ -3343,7 +3378,6 @@ function WorkspaceScreenContent({
tabs={desktopTabRowItems}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
setHoveredTabKey={setHoveredTabKey}
setHoveredCloseTabKey={setHoveredCloseTabKey}
onNavigateTab={navigateToTabId}
onCloseTab={handleCloseTabById}
@@ -3369,12 +3403,9 @@ function WorkspaceScreenContent({
<View style={styles.centerContent}>
{isMobile ? (
<GestureDetector
gesture={explorerOpenGesture}
touchAction={COMPACT_WEB_GESTURE_TOUCH_ACTION}
>
<View style={styles.content}>{content}</View>
</GestureDetector>
<MobileExplorerOpenGestureSurface onOpenExplorer={openExplorerForWorkspace}>
{content}
</MobileExplorerOpenGestureSurface>
) : (
<View style={styles.content}>{desktopContent}</View>
)}
@@ -3385,44 +3416,46 @@ function WorkspaceScreenContent({
return (
gatedWorkspaceScreen ?? (
<WorkspaceFocusProvider workspaceKey={persistenceKey}>
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<RenderProfile id="WorkspaceScreenContent">
<View style={containerStyle}>
<WorkspaceDocumentTitleEffectSlot
tab={activeTabDescriptor}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
isRouteFocused={isRouteFocused}
/>
<View style={styles.threePaneRow}>
<FloatingPanelPortalHostNameProvider hostName={workspaceFloatingPanelPortalHostName}>
{workspaceCenterColumn}
</FloatingPanelPortalHostNameProvider>
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
<FloatingPanelPortalHost name={workspaceFloatingPanelPortalHostName} />
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
{showExplorerSidebar && workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null}
</View>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
<ImportSessionSheet
visible={isImportSheetVisible}
client={client}
serverId={normalizedServerId}
cwd={workspaceDirectory}
onClose={closeImportSheet}
onImportedAgent={handleImportedAgent}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</RenderProfile>
</WorkspaceFocusProvider>
)
);