Improve workspace navigation and render isolation

This commit is contained in:
Mohamed Boudra
2026-04-20 16:14:22 +07:00
parent 403fb03c97
commit a6423f7f0a
46 changed files with 2801 additions and 862 deletions
@@ -0,0 +1,120 @@
/**
* @vitest-environment jsdom
*/
import { act } from "@testing-library/react";
import React, { useEffect } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildWorkspacePaneContentModel,
WorkspacePaneContent,
} from "@/screens/workspace/workspace-pane-content";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
import { usePaneContext, usePaneFocus, type PaneContextValue } from "@/panels/pane-context";
vi.mock("@/panels/register-panels", () => ({
ensurePanelsRegistered: vi.fn(),
}));
vi.mock("@/panels/panel-registry", () => ({
getPanelRegistration: () => ({
kind: "agent",
component: ProbePanel,
useDescriptor: vi.fn(),
}),
}));
interface ProbeSnapshot {
paneContextValue: PaneContextValue;
focus: ReturnType<typeof usePaneFocus>;
}
const snapshots: ProbeSnapshot[] = [];
const mountCount = vi.fn();
const unmountCount = vi.fn();
function ProbePanel() {
const paneContextValue = usePaneContext();
const focus = usePaneFocus();
snapshots.push({ paneContextValue, focus });
useEffect(() => {
mountCount();
return () => {
unmountCount();
};
}, []);
return null;
}
const tab: WorkspaceTabDescriptor = {
key: "agent_agent-a",
tabId: "agent_agent-a",
kind: "agent",
target: { kind: "agent", agentId: "agent-a" },
};
function buildContent() {
return buildWorkspacePaneContentModel({
tab,
normalizedServerId: "server-a",
normalizedWorkspaceId: "workspace-a",
onOpenTab: vi.fn(),
onCloseCurrentTab: vi.fn(),
onRetargetCurrentTab: vi.fn(),
onOpenWorkspaceFile: vi.fn(),
});
}
describe("WorkspacePaneContent", () => {
let root: Root | null = null;
let container: HTMLElement | null = null;
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container?.remove();
container = null;
snapshots.length = 0;
mountCount.mockClear();
unmountCount.mockClear();
});
it("updates focus without remounting panel content or replacing pane identity", () => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const content = buildContent();
act(() => {
root?.render(
<WorkspacePaneContent content={content} isPaneFocused={false} isWorkspaceFocused={true} />,
);
});
act(() => {
root?.render(
<WorkspacePaneContent content={content} isPaneFocused isWorkspaceFocused={true} />,
);
});
expect(mountCount).toHaveBeenCalledTimes(1);
expect(unmountCount).not.toHaveBeenCalled();
expect(snapshots).toHaveLength(2);
expect(snapshots[1]?.paneContextValue).toBe(snapshots[0]?.paneContextValue);
expect(snapshots[0]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: false,
isInteractive: false,
});
expect(snapshots[1]?.focus).toEqual({
isWorkspaceFocused: true,
isPaneFocused: true,
isInteractive: true,
});
});
});
@@ -1,6 +1,11 @@
import type { ComponentType } from "react";
import React, { useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import { PaneProvider, type PaneContextValue } from "@/panels/pane-context";
import {
createPaneFocusContextValue,
PaneFocusProvider,
PaneProvider,
type PaneContextValue,
} from "@/panels/pane-context";
import { getPanelRegistration } from "@/panels/panel-registry";
import { ensurePanelsRegistered } from "@/panels/register-panels";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -15,7 +20,6 @@ export interface BuildWorkspacePaneContentModelInput {
tab: WorkspaceTabDescriptor;
normalizedServerId: string;
normalizedWorkspaceId: string;
isPaneFocused: boolean;
onOpenTab: (target: WorkspaceTabDescriptor["target"]) => void;
onCloseCurrentTab: () => void;
onRetargetCurrentTab: (target: WorkspaceTabDescriptor["target"]) => void;
@@ -26,7 +30,6 @@ export function buildWorkspacePaneContentModel({
tab,
normalizedServerId,
normalizedWorkspaceId,
isPaneFocused,
onOpenTab,
onCloseCurrentTab,
onRetargetCurrentTab,
@@ -42,7 +45,6 @@ export function buildWorkspacePaneContentModel({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: tab.tabId,
isPaneFocused,
target: tab.target,
openTab: onOpenTab,
closeCurrentTab: onCloseCurrentTab,
@@ -54,14 +56,30 @@ export function buildWorkspacePaneContentModel({
export interface WorkspacePaneContentProps {
content: WorkspacePaneContentModel;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
}
export function WorkspacePaneContent({ content }: WorkspacePaneContentProps) {
export function WorkspacePaneContent({
content,
isWorkspaceFocused,
isPaneFocused,
}: WorkspacePaneContentProps) {
const { Component, key, paneContextValue } = content;
const paneFocusValue = useMemo(
() =>
createPaneFocusContextValue({
isWorkspaceFocused,
isPaneFocused,
}),
[isPaneFocused, isWorkspaceFocused],
);
return (
<PaneProvider value={paneContextValue}>
<Component key={key} />
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
);
}
@@ -48,11 +48,8 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-bu
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { usePanelStore } from "@/stores/panel-store";
import {
setActiveExplorerCheckout,
type ExplorerCheckoutContext,
} from "@/stores/explorer-checkout-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore } from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
@@ -513,11 +510,11 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
interface MobileMountedTabSlotProps {
tabDescriptor: WorkspaceTabDescriptor;
isVisible: boolean;
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
paneId: string | null;
buildPaneContentModel: (input: {
paneId: string | null;
isPaneFocused: boolean;
tab: WorkspaceTabDescriptor;
}) => WorkspacePaneContentModel;
}
@@ -525,6 +522,7 @@ interface MobileMountedTabSlotProps {
const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
tabDescriptor,
isVisible,
isWorkspaceFocused,
isPaneFocused,
paneId,
buildPaneContentModel,
@@ -533,15 +531,18 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
() =>
buildPaneContentModel({
paneId,
isPaneFocused,
tab: tabDescriptor,
}),
[buildPaneContentModel, isPaneFocused, paneId, tabDescriptor],
[buildPaneContentModel, paneId, tabDescriptor],
);
return (
<View style={{ display: isVisible ? "flex" : "none", flex: 1 }}>
<WorkspacePaneContent content={content} />
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
);
});
@@ -847,19 +848,19 @@ function WorkspaceScreenContent({
? trimNonEmpty(checkoutQuery.data.currentBranch)
: null;
const mobileView = usePanelStore((state) => state.mobileView);
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const showMobileFileExplorer = usePanelStore((state) => state.showMobileFileExplorer);
const toggleMobileFileExplorer = usePanelStore((state) => state.toggleMobileFileExplorer);
const openDesktopFileExplorer = usePanelStore((state) => state.openDesktopFileExplorer);
const toggleDesktopFileExplorer = usePanelStore((state) => state.toggleDesktopFileExplorer);
const activateExplorerTabForCheckout = usePanelStore(
(state) => state.activateExplorerTabForCheckout,
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,
);
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
if (!normalizedServerId || !workspaceDirectory) {
return null;
@@ -871,51 +872,28 @@ function WorkspaceScreenContent({
};
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
useEffect(() => {
if (!isRouteFocused) {
return;
}
setActiveExplorerCheckout(activeExplorerCheckout);
}, [activeExplorerCheckout, isRouteFocused, setActiveExplorerCheckout]);
const openExplorerForWorkspace = useCallback(() => {
if (!activeExplorerCheckout) {
return;
}
activateExplorerTabForCheckout(activeExplorerCheckout);
if (isMobile) {
showMobileFileExplorer();
return;
}
openDesktopFileExplorer();
}, [
activateExplorerTabForCheckout,
activeExplorerCheckout,
isMobile,
openDesktopFileExplorer,
showMobileFileExplorer,
]);
openFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, openFileExplorerForCheckout]);
const handleToggleExplorer = useCallback(() => {
if (isExplorerOpen) {
if (isMobile) {
toggleMobileFileExplorer();
return;
}
toggleDesktopFileExplorer();
if (!activeExplorerCheckout) {
return;
}
openExplorerForWorkspace();
}, [
isExplorerOpen,
isMobile,
openExplorerForWorkspace,
toggleDesktopFileExplorer,
toggleMobileFileExplorer,
]);
toggleFileExplorerForCheckout({
isCompact: isMobile,
checkout: activeExplorerCheckout,
});
}, [activeExplorerCheckout, isMobile, toggleFileExplorerForCheckout]);
const explorerOpenGesture = useExplorerOpenGesture({
enabled: isMobile && mobileView === "agent",
enabled: isMobile && canOpenExplorerFromAgentView,
onOpen: openExplorerForWorkspace,
});
@@ -1717,6 +1695,17 @@ function WorkspaceScreenContent({
],
);
const handleWorkspaceSidebarAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (action.id !== "sidebar.toggle.right") {
return false;
}
handleToggleExplorer();
return true;
},
[handleToggleExplorer],
);
const handleWorkspacePaneAction = useCallback(
(action: KeyboardActionDefinition): boolean => {
if (!persistenceKey || !workspaceLayout) {
@@ -1852,6 +1841,15 @@ function WorkspaceScreenContent({
handle: handleWorkspacePaneAction,
});
useKeyboardActionHandler({
handlerId: `workspace-sidebar-actions:${normalizedServerId}:${normalizedWorkspaceId}`,
actions: ["sidebar.toggle.right"] as const,
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
priority: 100,
isActive: () => true,
handle: handleWorkspaceSidebarAction,
});
const activeTabDescriptor = activeTab?.descriptor ?? null;
const canRenderDesktopPaneSplits = supportsDesktopPaneSplits();
const shouldRenderDesktopPaneFallback = !isMobile && !canRenderDesktopPaneSplits;
@@ -1865,14 +1863,12 @@ function WorkspaceScreenContent({
(input: {
tab: WorkspaceTabDescriptor;
paneId?: string | null;
isPaneFocused: boolean;
focusPaneBeforeOpen?: boolean;
}) =>
buildWorkspacePaneContentModel({
tab: input.tab,
normalizedServerId,
normalizedWorkspaceId,
isPaneFocused: isRouteFocused && input.isPaneFocused,
onOpenTab: (target) => {
if (!persistenceKey) {
return;
@@ -1908,7 +1904,6 @@ function WorkspaceScreenContent({
[
handleCloseTabById,
handleOpenFileFromChat,
isRouteFocused,
focusWorkspacePane,
navigateToTabId,
normalizedServerId,
@@ -1935,12 +1930,10 @@ function WorkspaceScreenContent({
function buildMobilePaneContentModel(input: {
paneId: string | null;
tab: WorkspaceTabDescriptor;
isPaneFocused: boolean;
}) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
isPaneFocused: input.isPaneFocused,
focusPaneBeforeOpen: false,
});
},
@@ -1983,7 +1976,8 @@ function WorkspaceScreenContent({
key={tabId}
tabDescriptor={tabDescriptor}
isVisible={isRouteFocused && tabId === activeTabDescriptor.tabId}
isPaneFocused={isRouteFocused && tabId === activeTabDescriptor.tabId}
isWorkspaceFocused={isRouteFocused}
isPaneFocused={tabId === activeTabDescriptor.tabId}
paneId={focusedPaneId}
buildPaneContentModel={buildMobilePaneContentModel}
/>
@@ -1992,15 +1986,10 @@ function WorkspaceScreenContent({
);
const buildDesktopPaneContentModel = useCallback(
function buildDesktopPaneContentModel(input: {
paneId: string;
tab: WorkspaceTabDescriptor;
isPaneFocused: boolean;
}) {
function buildDesktopPaneContentModel(input: { paneId: string; tab: WorkspaceTabDescriptor }) {
return buildPaneContentModel({
tab: input.tab,
paneId: input.paneId,
isPaneFocused: input.isPaneFocused,
focusPaneBeforeOpen: true,
});
},
@@ -2404,6 +2393,7 @@ function WorkspaceScreenContent({
workspaceKey={persistenceKey}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
isWorkspaceFocused={isRouteFocused}
uiTabs={uiTabs}
hoveredCloseTabKey={hoveredCloseTabKey}
setHoveredTabKey={setHoveredTabKey}