fix(app): yield navigation to narrow workspace explorer

This commit is contained in:
Mohamed Boudra
2026-07-14 22:44:03 +02:00
parent 9ffc632822
commit d630ab9c24
9 changed files with 206 additions and 70 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ The branching is one `useIsCompactFormFactor()` check at the top of the screen c
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
On a narrow desktop settings route, the app navigation yields to Settings' own 320px list + detail split when the remaining width cannot preserve its 400px detail target. That is a topology decision at the app container, not a second compact breakpoint.
On a narrow desktop route, app navigation yields to the rendered content topology when the remaining width cannot preserve its center target: Settings keeps its 320px list + 400px detail split, and a workspace Explorer keeps its current visible width plus a 400px center pane. That is a topology decision at the app container, not a second compact breakpoint. Temporary width clamps are render-only; widening restores the user's saved sidebar widths.
Electron window controls are top-corner obstructions, not a compact-layout condition. Rendered surfaces declare which top corners they physically occupy; only those corners receive clearance. Full-window overlays redeclare both corners. A focused split pane owns both corners; if focus restoration temporarily exposes the full split tree, the split boundary reserves one top strip instead of assigning a control rectangle to an arbitrarily narrow leaf. The 720px desktop breakpoint preserves the default 320px sidebar and target 400px center width when the Explorer is closed; it is product policy, not an obstruction gate.
@@ -183,4 +183,28 @@ test.describe("Half-screen desktop layout", () => {
await expect(page.getByTestId("settings-detail-pane")).toBeVisible();
await expect(page.getByTestId("sidebar-settings")).not.toBeVisible();
});
test("yields app navigation to the Explorer", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-half-screen-explorer-" });
try {
await gotoAppShell(page);
await waitForSidebarProject(page, path.basename(workspace.repoPath));
await openWorkspaceFromSidebar(page, workspace.workspaceId);
await page.getByTestId("workspace-explorer-toggle").first().click();
await expect(
page.getByTestId("explorer-tab-files").filter({ visible: true }).first(),
).toBeVisible();
await expect(page.getByTestId("sidebar-global-new-workspace")).not.toBeVisible();
await expect
.poll(
async () =>
(await page.getByTestId("workspace-tabs-row").first().boundingBox())?.width ?? 0,
)
.toBeGreaterThanOrEqual(400);
} finally {
await workspace.cleanup();
}
});
});
+17 -10
View File
@@ -37,12 +37,11 @@ import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
import {
getIsElectronRuntime,
SETTINGS_DESKTOP_SPLIT_MIN_WIDTH,
useIsCompactFormFactor,
} from "@/constants/layout";
import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width";
canDesktopAppSidebarShare,
resolveDesktopAppContentMinimum,
} from "@/components/desktop-sidebar-layout";
import { isNative, isWeb } from "@/constants/platform";
import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
import { SessionProvider } from "@/contexts/session-context";
@@ -416,7 +415,9 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
const explorerWidth = usePanelStore((state) => state.explorerWidth);
const { width: viewportWidth } = useWindowDimensions();
const cycleTheme = useCallback(() => {
@@ -470,16 +471,22 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
/>
);
const settingsCanShareAppSidebar =
!pathname.includes("/settings") ||
viewportWidth - resolveDesktopSidebarWidth({ requestedWidth: sidebarWidth, viewportWidth }) >=
SETTINGS_DESKTOP_SPLIT_MIN_WIDTH;
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth,
viewportWidth,
});
const desktopSidebarRendered =
!isCompactLayout &&
chromeEnabled &&
!isFocusModeEnabled &&
isDesktopAgentListOpen &&
settingsCanShareAppSidebar;
canDesktopAppSidebarShare({
contentMinimumWidth: appContentMinimumWidth,
requestedSidebarWidth: sidebarWidth,
viewportWidth,
});
const contentWindowChromeCorners = desktopSidebarRendered ? "top-right" : "both";
const workspaceChrome = (
<View style={rowStyle}>
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
canDesktopAppSidebarShare,
resolveDesktopAppContentMinimum,
resolveDesktopExplorerWidth,
resolveDesktopSidebarWidth,
} from "@/components/desktop-sidebar-layout";
describe("desktop sidebar layout", () => {
it("clamps a persisted wide sidebar to preserve the center pane", () => {
const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
expect(atHalfScreen).toBe(351);
expect(751 - atHalfScreen).toBe(400);
const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 });
expect(atBreakpoint).toBe(320);
expect(720 - atBreakpoint).toBe(400);
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
});
it("keeps a temporarily narrow explorer render-only", () => {
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351);
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400);
});
it("yields app navigation when settings or Explorer need the shell width", () => {
const settingsMinimum = resolveDesktopAppContentMinimum({
isSettingsRoute: true,
isWorkspaceExplorerOpen: false,
requestedExplorerWidth: 400,
viewportWidth: 751,
});
expect(settingsMinimum).toBe(720);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth: settingsMinimum,
requestedSidebarWidth: 320,
viewportWidth: 751,
}),
).toBe(false);
const explorerMinimum = resolveDesktopAppContentMinimum({
isSettingsRoute: false,
isWorkspaceExplorerOpen: true,
requestedExplorerWidth: 400,
viewportWidth: 751,
});
expect(explorerMinimum).toBe(751);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth: explorerMinimum,
requestedSidebarWidth: 320,
viewportWidth: 751,
}),
).toBe(false);
expect(
canDesktopAppSidebarShare({
contentMinimumWidth: resolveDesktopAppContentMinimum({
isSettingsRoute: false,
isWorkspaceExplorerOpen: true,
requestedExplorerWidth: 400,
viewportWidth: 1120,
}),
requestedSidebarWidth: 320,
viewportWidth: 1120,
}),
).toBe(true);
});
});
@@ -0,0 +1,76 @@
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
import {
MAX_EXPLORER_SIDEBAR_WIDTH,
MAX_SIDEBAR_WIDTH,
MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH,
} from "@/stores/panel-store";
export const MIN_DESKTOP_CENTER_WIDTH = 400;
function resolveDesktopPanelWidth(input: {
requestedWidth: number;
viewportWidth: number;
minimumWidth: number;
maximumWidth: number;
}): number {
"worklet";
const maximumVisibleWidth = Math.max(
input.minimumWidth,
Math.min(input.maximumWidth, input.viewportWidth - MIN_DESKTOP_CENTER_WIDTH),
);
return Math.max(input.minimumWidth, Math.min(maximumVisibleWidth, input.requestedWidth));
}
export function resolveDesktopSidebarWidth(input: {
requestedWidth: number;
viewportWidth: number;
}): number {
return resolveDesktopPanelWidth({
...input,
minimumWidth: MIN_SIDEBAR_WIDTH,
maximumWidth: MAX_SIDEBAR_WIDTH,
});
}
export function resolveDesktopExplorerWidth(input: {
requestedWidth: number;
viewportWidth: number;
}): number {
return resolveDesktopPanelWidth({
...input,
minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH,
maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH,
});
}
export function resolveDesktopAppContentMinimum(input: {
isSettingsRoute: boolean;
isWorkspaceExplorerOpen: boolean;
requestedExplorerWidth: number;
viewportWidth: number;
}): number {
const workspaceMinimum = input.isWorkspaceExplorerOpen
? MIN_DESKTOP_CENTER_WIDTH +
resolveDesktopExplorerWidth({
requestedWidth: input.requestedExplorerWidth,
viewportWidth: input.viewportWidth,
})
: 0;
return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum);
}
export function canDesktopAppSidebarShare(input: {
contentMinimumWidth: number;
requestedSidebarWidth: number;
viewportWidth: number;
}): boolean {
return (
input.viewportWidth -
resolveDesktopSidebarWidth({
requestedWidth: input.requestedSidebarWidth,
viewportWidth: input.viewportWidth,
}) >=
input.contentMinimumWidth
);
}
@@ -22,13 +22,7 @@ import {
} from "@/git/pull-request-panel";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
import {
usePanelStore,
selectIsFileExplorerOpen,
MIN_EXPLORER_SIDEBAR_WIDTH,
MAX_EXPLORER_SIDEBAR_WIDTH,
type ExplorerTab,
} from "@/stores/panel-store";
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
import { useToast } from "@/contexts/toast-context";
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
@@ -41,8 +35,8 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { RetainedPanelActivity } from "@/components/retained-panel";
import { isWeb } from "@/constants/platform";
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
const MIN_CHAT_WIDTH = 400;
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
@@ -163,18 +157,16 @@ export function ExplorerSidebar({
isGit,
});
const { width: viewportWidth } = useWindowDimensions();
const startWidthRef = useRef(explorerWidth);
const resizeWidth = useSharedValue(explorerWidth);
const visibleExplorerWidth = resolveDesktopExplorerWidth({
requestedWidth: explorerWidth,
viewportWidth,
});
const startWidthRef = useRef(visibleExplorerWidth);
const resizeWidth = useSharedValue(visibleExplorerWidth);
useEffect(() => {
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
if (explorerWidth > maxWidth) {
setExplorerWidth(maxWidth);
}
}, [explorerWidth, setExplorerWidth, viewportWidth]);
resizeWidth.value = visibleExplorerWidth;
}, [resizeWidth, visibleExplorerWidth]);
const handleDesktopClose = useCallback(() => {
logExplorerSidebar("handleClose", {
@@ -190,22 +182,20 @@ export function ExplorerSidebar({
.enabled(true)
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
.onStart(() => {
startWidthRef.current = explorerWidth;
resizeWidth.value = explorerWidth;
startWidthRef.current = visibleExplorerWidth;
resizeWidth.value = visibleExplorerWidth;
})
.onUpdate((event) => {
const newWidth = startWidthRef.current - event.translationX;
const maxWidth = Math.max(
MIN_EXPLORER_SIDEBAR_WIDTH,
Math.min(MAX_EXPLORER_SIDEBAR_WIDTH, viewportWidth - MIN_CHAT_WIDTH),
);
const clampedWidth = Math.max(MIN_EXPLORER_SIDEBAR_WIDTH, Math.min(maxWidth, newWidth));
resizeWidth.value = clampedWidth;
resizeWidth.value = resolveDesktopExplorerWidth({
requestedWidth: newWidth,
viewportWidth,
});
})
.onEnd(() => {
runOnJS(setExplorerWidth)(resizeWidth.value);
}),
[explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
[resizeWidth, setExplorerWidth, viewportWidth, visibleExplorerWidth],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width";
describe("desktop sidebar width", () => {
it("clamps a persisted wide sidebar to preserve 400px of content", () => {
const atHalfScreen = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 751 });
expect(atHalfScreen).toBe(351);
expect(751 - atHalfScreen).toBe(400);
const atBreakpoint = resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 720 });
expect(atBreakpoint).toBe(320);
expect(720 - atBreakpoint).toBe(400);
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
});
});
@@ -1,15 +0,0 @@
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
const MIN_CHAT_WIDTH = 400;
export function resolveDesktopSidebarWidth(input: {
requestedWidth: number;
viewportWidth: number;
}): number {
"worklet";
const maxWidth = Math.max(
MIN_SIDEBAR_WIDTH,
Math.min(MAX_SIDEBAR_WIDTH, input.viewportWidth - MIN_CHAT_WIDTH),
);
return Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxWidth, input.requestedWidth));
}
+1 -1
View File
@@ -25,7 +25,7 @@ import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-nativ
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { resolveDesktopSidebarWidth } from "@/components/left-sidebar-width";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";