From d48f1cc64e8b9cb90e45a48f8b5ede4069bd4a0e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 04:10:47 +0100 Subject: [PATCH] fix(app): choose workspace chrome within each desktop pane The outer width wrapper added a compact header while the desktop split container kept its full header and pane rails. Each pane now owns the complete choice, replacing its header and rail together. Each split pane uses its own measured width: below 500 points it shows its own tab dropdown, even if the neighboring pane is wide. Pane width determines usable header space. Keep new-tab keyboard ownership outside the switched chrome and retain positive measurements while hidden. Co-authored-by: Elsa Granger --- .../app/src/components/split-container.tsx | 125 +++++++--- .../workspace/adaptive-workspace-chrome.tsx | 31 +++ .../workspace/workspace-desktop-tabs-row.tsx | 70 ++++-- .../screens/workspace/workspace-screen.tsx | 227 +++++++++--------- .../workspace/workspace-tab-layout.test.ts | 12 + .../screens/workspace/workspace-tab-layout.ts | 9 + 6 files changed, 303 insertions(+), 171 deletions(-) create mode 100644 packages/app/src/screens/workspace/adaptive-workspace-chrome.tsx diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 278317243a..fc70f91232 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,3 +1,7 @@ +import { + AdaptiveWorkspaceChrome, + type WorkspaceCompactHeaderActions, +} from "@/screens/workspace/adaptive-workspace-chrome"; import { useWorkspaceTabDrag } from "./workspace-tab-drag-context"; import { Fragment, @@ -42,6 +46,7 @@ import type { WorkspacePaneContentModel } from "@/screens/workspace/workspace-pa import { WorkspacePanelHost } from "@/screens/workspace/workspace-panel-host"; import { WorkspaceDesktopTabsRow, + useWorkspaceNewTabKeyboardAction, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -61,6 +66,11 @@ import { isNative } from "@/constants/platform"; interface SplitContainerProps { layout: WorkspaceLayout; renderMainHeader?: () => ReactNode; + renderCompactHeader: ( + tabs: WorkspaceTabDescriptor[], + activeTabKey: string, + actions: WorkspaceCompactHeaderActions, + ) => ReactNode; workspaceKey: string; normalizedServerId: string; normalizedWorkspaceId: string; @@ -145,7 +155,6 @@ const EMPTY_SPLIT_SIZES: number[] = []; export function SplitContainer({ layout, - renderMainHeader, workspaceKey, normalizedServerId, normalizedWorkspaceId, @@ -174,6 +183,8 @@ export function SplitContainer({ onReorderTabsInPane, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitContainerProps) { const inheritedWindowChromeCorners = useWindowChromeCorners(); const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none"; @@ -237,7 +248,6 @@ export function SplitContainer({ - {renderMainHeader?.()} {splitRoot.usesFallbackStrip && } {renderRoot ? ( ) : null} @@ -410,6 +422,8 @@ function SplitNodeView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitNodeViewProps) { const [groupContainerSize, setGroupContainerSize] = useState(0); const groupId = node.kind === "group" ? node.group.id : null; @@ -496,6 +510,8 @@ function SplitNodeView({ onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + renderMainHeader={renderMainHeader} + renderCompactHeader={renderCompactHeader} /> @@ -550,6 +566,8 @@ function SplitNodeView({ onTogglePaneMaximized={onTogglePaneMaximized} focusModeEnabled={focusModeEnabled} onExitFocusMode={onExitFocusMode} + renderMainHeader={renderMainHeader} + renderCompactHeader={renderCompactHeader} /> {index < node.group.children.length - 1 && @@ -608,6 +626,8 @@ const SplitPaneView = memo(function SplitPaneView({ onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, + renderMainHeader, + renderCompactHeader, }: SplitPaneViewProps) { recordRenderProfileReasons(`SplitPaneView:${pane.id}`, ["render"]); const paneRef = useRef(null); @@ -701,6 +721,23 @@ const SplitPaneView = memo(function SplitPaneView({ () => onTogglePaneMaximized(paneId), [onTogglePaneMaximized, paneId], ); + const compactHeaderActions = useMemo( + () => ({ + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }), + [handleCloseTabsToLeft, handleCloseTabsToRight, handleCloseOtherTabs], + ); + const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); + useWorkspaceNewTabKeyboardAction({ + handlerName: "workspace-chrome-new-tab", + ownsKeyboardShortcuts: isFocused && isWorkspaceFocused, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, + }); return ( - - - - + + {renderMainHeader?.()} + + + + + (null); + const measure = useCallback((event: LayoutChangeEvent) => { + const measured = event.nativeEvent.layout.width; + if (measured > 0) setWidth(measured); + }, []); + return ( + + + {selectWorkspaceChrome({ width, compact: compactHeader, full: children })} + + + ); +} + +export interface WorkspaceCompactHeaderActions { + closeAbove: (key: string) => Promise | void; + closeBelow: (key: string) => Promise | void; + closeOthers: (key: string) => Promise | void; +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 9d372f0cf3..540ddd8fde 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -967,6 +967,47 @@ function TabChip({ ); } +export function useWorkspaceNewTabKeyboardAction({ + handlerName = "workspace-new-tab", + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, +}: { + handlerName?: string; + ownsKeyboardShortcuts: boolean; + normalizedServerId: string; + normalizedWorkspaceId: string; + paneId?: string; + createNewTab: () => void; +}) { + const handleNewTabKeyboardAction = useCallback( + (action: KeyboardActionDefinition): boolean => { + if (!ownsKeyboardShortcuts) return false; + if (action.id === "workspace.tab.menu.open") { + createNewTab(); + return true; + } + return false; + }, + [createNewTab, ownsKeyboardShortcuts], + ); + + useKeyboardActionHandler({ + handlerId: buildWorkspaceKeyboardHandlerId({ + name: handlerName, + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + paneId, + }), + actions: ["workspace.tab.menu.open"], + enabled: ownsKeyboardShortcuts, + priority: 200, + handle: handleNewTabKeyboardAction, + }); +} + export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { const [presentations, setPresentations] = useState( () => new Map(), @@ -1259,29 +1300,12 @@ function ResolvedWorkspaceDesktopTabsRow({ const createNewTab = useCallback(() => onCreateNewTab({ paneId }), [onCreateNewTab, paneId]); - const handleNewTabKeyboardAction = useCallback( - (action: KeyboardActionDefinition): boolean => { - if (!ownsKeyboardShortcuts) return false; - if (action.id === "workspace.tab.menu.open") { - createNewTab(); - return true; - } - return false; - }, - [createNewTab, ownsKeyboardShortcuts], - ); - - useKeyboardActionHandler({ - handlerId: buildWorkspaceKeyboardHandlerId({ - name: "workspace-new-tab", - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - paneId, - }), - actions: ["workspace.tab.menu.open"], - enabled: ownsKeyboardShortcuts, - priority: 200, - handle: handleNewTabKeyboardAction, + useWorkspaceNewTabKeyboardAction({ + ownsKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId, + createNewTab, }); const rail: WorkspaceTabRailSurface = host === "explorer" ? "sidebar" : "surface"; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 22839d103f..92d1cb5825 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,4 +1,7 @@ -import { shouldUseCompactWorkspaceHeader } from "./workspace-tab-layout"; +import { + AdaptiveWorkspaceChrome, + type WorkspaceCompactHeaderActions, +} from "./adaptive-workspace-chrome"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { useHostFeature } from "@/runtime/host-features"; import { @@ -148,7 +151,7 @@ import { } from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, - WorkspaceTabMeasurementScope, + useWorkspaceNewTabKeyboardAction, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; @@ -788,27 +791,6 @@ function WorkspaceContentProviders({ ); } -function AdaptiveWorkspaceChrome({ - compactHeader, - children, -}: { - compactHeader: ReactNode; - children: ReactNode; -}) { - const [width, setWidth] = useState(null); - const measure = useCallback( - (event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width), - [], - ); - return ( - - - {width !== null && shouldUseCompactWorkspaceHeader(width) ? compactHeader : children} - - - ); -} - function WorkspacePanelSurface({ launcher, children, @@ -3384,69 +3366,71 @@ function WorkspaceScreenContent({ handleOpenUrlInBrowserTab, ], ); - const compactWorkspaceHeader = useMemo( - () => ( - - - - - ), + const renderCompactWorkspaceHeader = useCallback( + ( + headerTabs: WorkspaceTabDescriptor[], + headerActiveTabKey: string, + actions: WorkspaceCompactHeaderActions, + ) => { + return ( + + + + + ); + }, [ - activeTabKey, canOpenImportSheet, compactHeaderScripts, createTerminalDisabled, currentBranchName, explorerSidebarToggleAccessibilityState, explorerSidebarToggleLabel, - handleCloseOtherTabs, handleCloseTabById, - handleCloseTabsToLeft, - handleCloseTabsToRight, handleCopyAgentId, handleCopyBranchName, handleCopyFilePath, @@ -3469,12 +3453,16 @@ function WorkspaceScreenContent({ openImportSheet, showCreateBrowserTab, showWorkspaceSetup, - tabs, workspaceDirectory, workspaceHeaderSubtitle, workspaceHeaderTitle, ], ); + const compactWorkspaceHeader = renderCompactWorkspaceHeader(tabs, activeTabKey, { + closeAbove: handleCloseTabsToLeft, + closeBelow: handleCloseTabsToRight, + closeOthers: handleCloseOtherTabs, + }); const desktopSplitContent = useMemo(() => { if (!canRenderDesktopPaneSplits || !workspaceLayout || !persistenceKey) { return null; @@ -3483,6 +3471,7 @@ function WorkspaceScreenContent({ handleCreateNewTab({ paneId: focusedPaneIdOrUndefined }), + [handleCreateNewTab, focusedPaneIdOrUndefined], + ); + const ownsFallbackKeyboardShortcuts = useMemo( + () => shouldRenderDesktopPaneFallback && isRouteFocused, + [shouldRenderDesktopPaneFallback, isRouteFocused], + ); + useWorkspaceNewTabKeyboardAction({ + handlerName: "workspace-chrome-new-tab", + ownsKeyboardShortcuts: ownsFallbackKeyboardShortcuts, + normalizedServerId, + normalizedWorkspaceId, + paneId: focusedPaneIdOrUndefined, + createNewTab: createFallbackTab, + }); const compactHeaderLayout = resolveCompactHeaderLayout({ header: compactWorkspaceHeader, activeTab: activeTabDescriptor, @@ -3580,37 +3586,40 @@ function WorkspaceScreenContent({ ) : ( - - {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} + {rendersDesktopSplitContent ? null : ( + + {renderWorkspaceScreenHeader()} - {shouldRenderDesktopPaneFallback ? ( - - - - ) : null} - + {shouldRenderDesktopPaneFallback ? ( + + + + ) : null} + + )} {desktopContent} diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts index 1ba6e05f01..afc5275a46 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.test.ts @@ -7,6 +7,7 @@ import { SidebarCloseButton } from "@/components/headers/sidebar-close-button"; import { resolveWorkspaceTabChipBackdrop } from "@/screens/workspace/workspace-tab-chip-backdrop"; import { darkPureBlackTheme, darkTheme, lightTheme } from "@/styles/theme"; import { + selectWorkspaceChrome, computeWorkspaceTabLayout, retainWorkspaceTabMeasuredWidth, } from "@/screens/workspace/workspace-tab-layout"; @@ -266,3 +267,14 @@ it.each([ ])("workspace width %s selects compact=%s", (width, compact) => { expect(shouldUseCompactWorkspaceHeader(width)).toBe(compact); }); + +describe("desktop pane chrome", () => { + it("replaces the pane header and rail together at the pane width boundary", () => { + const full = ["workspace header", "pane tab rail"]; + expect(selectWorkspaceChrome({ width: 440, compact: "pane dropdown", full })).toBe( + "pane dropdown", + ); + expect(selectWorkspaceChrome({ width: 500, compact: "pane dropdown", full })).toBe(full); + expect(selectWorkspaceChrome({ width: null, compact: "pane dropdown", full })).toBe(full); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-tab-layout.ts b/packages/app/src/screens/workspace/workspace-tab-layout.ts index 94aad3c1b6..137a6f7b7b 100644 --- a/packages/app/src/screens/workspace/workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/workspace-tab-layout.ts @@ -126,3 +126,12 @@ export function computeWorkspaceTabLayout( requiresHorizontalScrollFallback, }; } + +export function selectWorkspaceChrome(input: { + width: number | null; + compact: Compact; + full: Full; +}): Compact | Full { + const compact = input.width !== null && shouldUseCompactWorkspaceHeader(input.width); + return compact ? input.compact : input.full; +}