diff --git a/src/renderer/src/app-shell/AppWorkspaceShell.tsx b/src/renderer/src/app-shell/AppWorkspaceShell.tsx index 4be638694f1d..467fce4bca7e 100644 --- a/src/renderer/src/app-shell/AppWorkspaceShell.tsx +++ b/src/renderer/src/app-shell/AppWorkspaceShell.tsx @@ -59,6 +59,9 @@ function WorktreeSidebar({ ) diff --git a/src/renderer/src/app-shell/TitlebarLeftControls.tsx b/src/renderer/src/app-shell/TitlebarLeftControls.tsx index 6f9f0afda9f8..58920bf163aa 100644 --- a/src/renderer/src/app-shell/TitlebarLeftControls.tsx +++ b/src/renderer/src/app-shell/TitlebarLeftControls.tsx @@ -42,7 +42,7 @@ export function TitlebarLeftControls({ layout }: { layout: AppChromeLayout }): R layout.leftTitlebarChromeLayout.isFloating ? ' w-max' : ' w-full' }`} > -
+
{hasMacTrafficLights && !layout.isFullScreen ? (
) : hasCustomTitleBar ? ( diff --git a/src/renderer/src/app-shell/titlebar-left-min-width.ts b/src/renderer/src/app-shell/titlebar-left-min-width.ts new file mode 100644 index 000000000000..d7a3b14eb3cd --- /dev/null +++ b/src/renderer/src/app-shell/titlebar-left-min-width.ts @@ -0,0 +1,33 @@ +function horizontalPx(style: CSSStyleDeclaration, ...properties: string[]): number { + return properties.reduce( + (sum, property) => sum + (Number.parseFloat(style.getPropertyValue(property)) || 0), + 0 + ) +} + +/** Width the left titlebar row needs once the app name, its only shrinkable control, is squeezed to its padding. */ +export function measureTitlebarLeftMinWidth(row: HTMLElement): number { + let width = 0 + for (const child of row.children) { + if (!(child instanceof HTMLElement)) { + continue + } + // scrollWidth keeps content that overflows a shrunk group; left margins are auto free space, not need. + width += + Math.max(child.scrollWidth, child.getBoundingClientRect().width) + + horizontalPx(getComputedStyle(child), 'margin-right') + } + const appName = row.querySelector('.titlebar-app-name') + if (appName instanceof HTMLElement) { + width -= + appName.getBoundingClientRect().width - + horizontalPx( + getComputedStyle(appName), + 'padding-left', + 'padding-right', + 'border-left-width', + 'border-right-width' + ) + } + return Math.ceil(width) +} diff --git a/src/renderer/src/app-shell/use-app-chrome-layout.ts b/src/renderer/src/app-shell/use-app-chrome-layout.ts index 3a243b9496ad..af5c46e88d96 100644 --- a/src/renderer/src/app-shell/use-app-chrome-layout.ts +++ b/src/renderer/src/app-shell/use-app-chrome-layout.ts @@ -6,6 +6,7 @@ import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' import { useAppStore } from '../store' +import { measureTitlebarLeftMinWidth } from './titlebar-left-min-width' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector' import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark' @@ -94,6 +95,7 @@ export function useAppChromeLayout() { const titlebarLeftControlsRef = useRef(null) const [collapsedSidebarHeaderWidth, setCollapsedSidebarHeaderWidth] = useState(0) + const [titlebarLeftMinWidth, setTitlebarLeftMinWidth] = useState(0) useLayoutEffect(() => { const controls = titlebarLeftControlsRef.current if (!controls) { @@ -102,6 +104,7 @@ export function useAppChromeLayout() { const updateWidth = (): void => { setCollapsedSidebarHeaderWidth(controls.getBoundingClientRect().width) + setTitlebarLeftMinWidth(measureTitlebarLeftMinWidth(controls)) } updateWidth() @@ -109,8 +112,16 @@ export function useAppChromeLayout() { updateWidth() }) observer.observe(controls) + // Why: the zoom-scaled traffic-light pad can grow while the full-width row keeps its size. + for (const group of controls.children) { + observer.observe(group) + for (const control of group.children) { + observer.observe(control) + } + } return () => observer.disconnect() }, [ + activeView, isFullScreen, settings?.showTitlebarAppName, showSidebar, @@ -142,6 +153,7 @@ export function useAppChromeLayout() { // Retained workbench panes need a separate visibility condition. terminalWorkbenchVisible: workspaceChromeActive, titlebarLeftControlsRef, + titlebarLeftMinWidth, workspaceChromeActive } } diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 67352d82a80c..ad30a8799755 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -981,6 +981,11 @@ html.native-shell .app-layout { user-select: none; } +/* Why: the sidebar alone sizes the left column; wider header controls clip instead of widening it past a shrunk sidebar. */ +.titlebar-left:not(.titlebar-left-floating) { + contain: inline-size; +} + /* Why: when the sidebar is collapsed this header floats over the tab strip, so keep the standard titlebar surface instead of the sidebar tint. */ .titlebar-left-floating { diff --git a/src/renderer/src/components/sidebar/index.tsx b/src/renderer/src/components/sidebar/index.tsx index bed00b3d4a0f..c0301accb3ee 100644 --- a/src/renderer/src/components/sidebar/index.tsx +++ b/src/renderer/src/components/sidebar/index.tsx @@ -45,11 +45,14 @@ export const WORKTREE_SIDEBAR_RESIZE_HANDLE_LINE_CLASS_NAME = type SidebarProps = { worktreeScrollOffsetRef: React.MutableRefObject worktreeScrollAnchorRef: React.MutableRefObject + /** Width the titlebar header above the sidebar needs for its non-shrinking controls. */ + headerMinWidth?: number } function Sidebar({ worktreeScrollOffsetRef, - worktreeScrollAnchorRef + worktreeScrollAnchorRef, + headerMinWidth = 0 }: SidebarProps): React.JSX.Element { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const sidebarWidth = useAppStore((s) => s.sidebarWidth) @@ -138,10 +141,12 @@ function Sidebar({ } }, [agentDashboardDrawerOpen, setAgentDashboardDrawerOpen, showAgentDashboard]) + // Why: zooming out widens the traffic-light pad past MIN_WIDTH; render at least that wide without rewriting the stored width. + const minWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, headerMinWidth)) const { containerRef, onResizeStart, isResizing } = useSidebarResize({ isOpen: sidebarOpen, - width: sidebarWidth, - minWidth: MIN_WIDTH, + width: Math.max(sidebarWidth, minWidth), + minWidth, maxWidth: MAX_WIDTH, deltaSign: 1, setWidth: setSidebarWidth,