feat(ui): full-height left sidebar with decoupled header layout

Rework the desktop layout so the left sidebar spans the full window
height and reads as a distinct column, separate from the header.

- Persistent top-left controls (sidebar toggle + project actions) as a
  single overlay that no longer migrates between header and sidebar, so
  it stays put while panels animate. Drag regions carve out only under
  the buttons so the strip stays draggable and the buttons clickable.
- Header sits in the content column (bg-background); the divider lives
  on the chat content (border-t) so it doesn't run between header and
  right sidebar. Right sidebar gets a top border + small content padding.
- Drop the rounded chat card and corner masks for flat 1px borders.
- Right sidebar, context panel and git/files views inherit the
  background color instead of bg-sidebar.
- Remove hover background from Changes/Staged rows.
- Mini-chat header: borderless, bg-background.
This commit is contained in:
Bohdan Triapitsyn
2026-06-09 15:51:04 +03:00
parent 25da8493fe
commit f9acf68c8f
13 changed files with 338 additions and 151 deletions
+63 -28
View File
@@ -706,6 +706,7 @@ export const Header: React.FC<HeaderProps> = ({
const { t } = useI18n();
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const toggleBottomTerminal = useUIStore((state) => state.toggleBottomTerminal);
const toggleRightSidebar = useUIStore((state) => state.toggleRightSidebar);
const openContextOverview = useUIStore((state) => state.openContextOverview);
@@ -1575,12 +1576,42 @@ export const Header: React.FC<HeaderProps> = ({
onToggleRightDrawer?.();
}, [onToggleRightDrawer, rightDrawerOpen]);
const desktopPaddingClass = React.useMemo(() => {
if ((isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) || isTabletStandalonePwa) {
return 'pl-[5.5rem]';
// Left padding the header needs to clear the OS window controls (macOS
// traffic lights / window-controls-overlay). When the sidebar is open this
// space is owned by the sidebar's top strip instead, so the header drops back
// to its normal content padding. The full value is published as
// `--oc-titlebar-left-inset` so the sidebar strip can mirror it.
const titlebarLeftInset = React.useMemo(() => {
if (isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) {
return '5.5rem';
}
return 'pl-3';
}, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isTabletStandalonePwa]);
if (isTabletStandalonePwa) {
return 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)';
}
if ((!isDesktopApp || isWindowsElectronDesktop) && !isVSCode) {
return 'calc(0.75rem + var(--oc-wco-left-inset, 0px))';
}
return '0.75rem';
}, [isDesktopApp, isDesktopWindowFullscreen, isMacPlatform, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]);
useEffect(() => {
if (typeof document === 'undefined') {
return;
}
document.documentElement.style.setProperty('--oc-titlebar-left-inset', titlebarLeftInset);
}, [titlebarLeftInset]);
// Space reserved on the header's left for the persistent overlay when the
// sidebar is collapsed (the overlay sits over the header then). Split into two
// spacers so the strip stays a window drag area while the buttons stay
// clickable: a drag region for the window-controls inset (traffic lights) and
// a no-drag carve under the control cluster. Both animate so the session title
// slides in/out in lockstep with the sidebar. When the sidebar is open the
// overlay is over the sidebar, so the header only keeps normal content padding.
const headerInsetSpacerWidth = isSidebarOpen ? '0.75rem' : 'var(--oc-titlebar-left-inset, 0.75rem)';
const headerControlsSpacerWidth = isSidebarOpen
? '0px'
: 'calc(var(--oc-titlebar-controls-width, 5.5rem) + 0.5rem)';
useEffect(() => {
if (!isDesktopApp || !isMacPlatform) {
@@ -1635,14 +1666,13 @@ export const Header: React.FC<HeaderProps> = ({
}
return {
paddingLeft: isTabletStandalonePwa
? 'max(calc(0.75rem + var(--oc-wco-left-inset, 0px)), 5.5rem)'
: 'calc(0.75rem + var(--oc-wco-left-inset, 0px))',
// Left inset is handled by the no-drag spacer (see renderDesktop); only
// the right inset / titlebar height are owned by the window-controls overlay.
paddingRight: 'calc(0.75rem + var(--oc-wco-right-inset, 0px))',
minHeight: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
height: 'max(3rem, var(--oc-wco-titlebar-height, 0px))',
};
}, [isDesktopApp, isTabletStandalonePwa, isVSCode, isWindowsElectronDesktop]);
}, [isDesktopApp, isVSCode, isWindowsElectronDesktop]);
const updateHeaderHeight = React.useCallback(() => {
if (typeof document === 'undefined') {
@@ -2061,13 +2091,27 @@ export const Header: React.FC<HeaderProps> = ({
onMouseDown={handleDragStart}
className={cn(
'app-region-drag relative flex h-12 select-none items-center pr-3',
desktopPaddingClass,
macosHeaderSizeClass
)}
style={webWindowControlsOverlayStyle}
role="tablist"
aria-label={t('header.navigation.mainAria')}
>
{/* Drag region for the window-controls inset (traffic lights) to the left
of the overlay buttons — stays a window drag area. */}
<div
aria-hidden
className="shrink-0 self-stretch transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{ width: headerInsetSpacerWidth }}
/>
{/* No-drag carve under the persistent TitlebarLeftControls overlay so its
buttons stay clickable. Width animates with the sidebar so the session
title slides in lockstep instead of snapping. */}
<div
aria-hidden
className="app-region-no-drag shrink-0 self-stretch transition-[width] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{ width: headerControlsSpacerWidth }}
/>
{isWindowsElectronDesktop ? (
<HeaderIconActionButton
title={t('header.actions.openAppMenu')}
@@ -2077,22 +2121,10 @@ export const Header: React.FC<HeaderProps> = ({
Icon={'menu-2'}
/>
) : null}
<HeaderIconActionButton
title={t('header.actions.openSessionsWithShortcut', { shortcut: shortcutLabel('toggle_sidebar') })}
ariaLabel={t('header.actions.openSessionsAria')}
onClick={handleOpenSessionSwitcher}
className={`${desktopHeaderIconButtonClass} shrink-0`}
Icon={'layout-left'}
/>
<div className="flex min-w-0 flex-1 items-center pl-3">
{projectActionsContext && (
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
directory={projectActionsContext.directory}
className="mr-2"
/>
)}
{/* Sidebar toggle + project actions live in the persistent
TitlebarLeftControls overlay; the header reserves matching left space
via padding (see headerStyle) when the sidebar is collapsed. */}
<div className="flex min-w-0 flex-1 items-center">
<SessionSwitcherDropdown>
<button
type="button"
@@ -2595,8 +2627,11 @@ export const Header: React.FC<HeaderProps> = ({
);
const headerClassName = cn(
'header-safe-area relative z-10',
isMobile ? 'border-b border-border/50 bg-background' : 'bg-sidebar'
'header-safe-area relative z-10 bg-background',
// Mobile keeps a full-width divider. On desktop the divider lives on the chat
// content wrapper instead, so it doesn't run between the header and the right
// sidebar (they read as one continuous surface).
isMobile && 'border-b border-border/50'
);
return (
+50 -111
View File
@@ -2,8 +2,10 @@ import React, { useRef, useEffect } from 'react';
import { animate, motion, useMotionValue } from 'motion/react';
import { Header } from './Header';
import { BottomTerminalDock } from './BottomTerminalDock';
import { Sidebar, SIDEBAR_CONTENT_WIDTH } from './Sidebar';
import { RightSidebar, RIGHT_SIDEBAR_CONTENT_WIDTH } from './RightSidebar';
import { Sidebar } from './Sidebar';
import { SidebarTopBar } from './SidebarTopBar';
import { TitlebarLeftControls } from './TitlebarLeftControls';
import { RightSidebar } from './RightSidebar';
import { ProjectContextPanel, RightSidebarTabs } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ErrorBoundary } from '../ui/ErrorBoundary';
@@ -36,11 +38,6 @@ const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/Sett
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
const DESKTOP_SIDEBAR_MIN_WIDTH = 280;
const DESKTOP_SIDEBAR_MAX_WIDTH = 500;
const DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH = 360;
const DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH = 860;
export const MainLayout: React.FC = () => {
const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140;
const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220;
@@ -60,8 +57,6 @@ export const MainLayout: React.FC = () => {
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const { isMobile, isTablet } = useDeviceInfo();
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
const rightSidebarAutoClosedRef = React.useRef(false);
const bottomTerminalAutoClosedRef = React.useRef(false);
const mobilePanelsResetRef = React.useRef(false);
@@ -417,14 +412,6 @@ export const MainLayout: React.FC = () => {
}, [activeMainTab]);
const isChatActive = activeMainTab === 'chat';
const visibleSidebarWidth = React.useMemo(() => {
const rawWidth = sidebarWidth || SIDEBAR_CONTENT_WIDTH;
return Math.min(DESKTOP_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_SIDEBAR_MIN_WIDTH, rawWidth));
}, [sidebarWidth]);
const visibleRightSidebarWidth = React.useMemo(() => {
const rawWidth = rightSidebarWidth || RIGHT_SIDEBAR_CONTENT_WIDTH;
return Math.min(DESKTOP_RIGHT_SIDEBAR_MAX_WIDTH, Math.max(DESKTOP_RIGHT_SIDEBAR_MIN_WIDTH, rawWidth));
}, [rightSidebarWidth]);
return (
<DiffWorkerProvider>
@@ -432,7 +419,7 @@ export const MainLayout: React.FC = () => {
data-page-scroll-lock="true"
className={cn(
'main-content-safe-area',
isMobile ? 'flex h-[100dvh] flex-col' : 'flex h-[100dvh]',
isMobile ? 'flex h-[100dvh] flex-col' : 'relative flex h-[100dvh]',
'bg-background'
)}
>
@@ -537,103 +524,55 @@ export const MainLayout: React.FC = () => {
</DrawerProvider>
) : (
<>
{/* Desktop: full-width Header above [Sidebar | chat-frame | RightSidebar] row */}
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-sidebar" data-page-scroll-lock="true">
<div
aria-hidden
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
opacity: isSidebarOpen ? 1 : 0,
width: '10px',
height: '10px',
WebkitMaskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
maskImage: 'radial-gradient(circle at 100% 100%, transparent calc(10px - 1px), black 10px)',
}}
/>
<div
aria-hidden
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[left,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{
left: `${isSidebarOpen ? visibleSidebarWidth : 0}px`,
opacity: isSidebarOpen ? 1 : 0,
width: '10px',
height: '10px',
WebkitMaskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
maskImage: 'radial-gradient(circle at 100% 0%, transparent calc(10px - 1px), black 10px)',
}}
/>
<div
aria-hidden
className="pointer-events-none absolute top-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
opacity: isRightSidebarOpen ? 1 : 0,
width: '10px',
height: '10px',
WebkitMaskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
maskImage: 'radial-gradient(circle at 0 100%, transparent calc(10px - 1px), black 10px)',
}}
/>
<div
aria-hidden
className="pointer-events-none absolute bottom-0 z-0 bg-sidebar transition-[right,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
style={{
right: `${isRightSidebarOpen ? visibleRightSidebarWidth : 0}px`,
opacity: isRightSidebarOpen ? 1 : 0,
width: '10px',
height: '10px',
WebkitMaskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
maskImage: 'radial-gradient(circle at 0 0, transparent calc(10px - 1px), black 10px)',
}}
/>
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-0"
>
<SessionSidebar />
</Sidebar>
<div className={cn(
'relative flex flex-1 min-w-0 flex-col overflow-hidden',
'bg-background',
'border border-border/50 rounded-[10px]',
!isSidebarOpen && 'border-l-transparent',
!isRightSidebarOpen && 'border-r-transparent'
)} data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border/50"
topBar={<SidebarTopBar />}
>
<SessionSidebar />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border/50 bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary>
</div>
)}
</main>
<ContextPanel />
{secondaryView && (
<div className="absolute inset-0">
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
</main>
<ContextPanel />
</div>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
{isBottomTerminalOpen ? (
<ErrorBoundary>
<React.Suspense fallback={null}>
<TerminalView />
</React.Suspense>
</ErrorBoundary>
) : null}
</BottomTerminalDock>
</div>
<BottomTerminalDock isOpen={isBottomTerminalOpen} isMobile={isMobile}>
{isBottomTerminalOpen ? (
<ErrorBoundary>
<React.Suspense fallback={null}>
<TerminalView />
</React.Suspense>
</ErrorBoundary>
) : null}
</BottomTerminalDock>
<RightSidebar
isOpen={isRightSidebarOpen}
className="bg-background border-t border-border/50"
>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
<RightSidebar
isOpen={isRightSidebarOpen}
className="border-0"
>
<ErrorBoundary><RightSidebarTabs /></ErrorBoundary>
</RightSidebar>
</div>
</div>
@@ -143,7 +143,7 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
)}
<div
className={cn(
'relative z-10 flex h-full min-h-0 shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
'relative z-10 flex h-full min-h-0 shrink-0 flex-col pt-2 transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
isResizing && 'pointer-events-none',
!isOpen && 'pointer-events-none select-none opacity-0'
)}
@@ -80,7 +80,7 @@ export const ProjectContextPanel: React.FC = () => {
}, [activeProject, gitDirectories]);
return (
<div className="h-full min-h-0 overflow-auto bg-sidebar">
<div className="h-full min-h-0 overflow-auto bg-background">
<ProjectNotesTodoPanel
projectRef={projectRef}
projectLabel={projectLabel}
@@ -118,8 +118,8 @@ export const RightSidebarTabs: React.FC = () => {
], [t]);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
<div className="h-9 bg-sidebar pt-1 px-2">
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div className="h-9 bg-background pt-1 px-2">
<SortableTabsStrip
items={tabItems}
activeId={rightSidebarTab}
@@ -13,9 +13,11 @@ interface SidebarProps {
isMobile: boolean;
children: React.ReactNode;
className?: string;
/** Fixed strip rendered above the scrollable content (e.g. toggle + project actions). */
topBar?: React.ReactNode;
}
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className }) => {
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, className, topBar }) => {
const { t } = useI18n();
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const setSidebarWidth = useUIStore((state) => state.setSidebarWidth);
@@ -165,7 +167,8 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children, cl
style={{ width: 'var(--oc-left-sidebar-width)', overflowX: 'hidden' }}
aria-hidden={!isOpen}
>
<div className="flex-1 overflow-y-auto">
{topBar}
<div className="min-h-0 flex-1 overflow-y-auto">
<ErrorBoundary>{children}</ErrorBoundary>
</div>
</div>
@@ -829,7 +829,7 @@ export const SidebarFilesTree: React.FC = () => {
const rootLoadError = root ? loadErrorsByDir[root] : null;
return (
<section className="flex h-full min-h-0 flex-col overflow-hidden bg-sidebar">
<section className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex items-center gap-2 border-b border-border/40 px-3 py-2">
<div className="relative min-w-0 flex-1">
<Icon name="search" className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
@@ -0,0 +1,34 @@
import React from 'react';
/**
* Strip at the top of the desktop left sidebar that reserves room for the
* persistent {@link TitlebarLeftControls} overlay (sidebar toggle + project
* actions), so the session list starts below them. Its height tracks the
* header via `--oc-header-height`.
*
* Split into two regions so the strip stays a window drag area while the
* overlay buttons remain clickable: a `no-drag` carve matching the overlay
* footprint (the overlay sits on top of it; an OS drag region here would steal
* the buttons' clicks, since a separate-subtree `no-drag` can't carve a drag
* region in Electron/macOS) plus a `drag` remainder for window dragging.
*/
export const SidebarTopBar: React.FC = () => (
<div
aria-hidden
className="flex shrink-0"
style={{ height: 'var(--oc-header-height, 3rem)' }}
>
{/* Drag region for the window-controls inset (traffic lights). */}
<div
className="app-region-drag shrink-0"
style={{ width: 'var(--oc-titlebar-left-inset, 0.75rem)' }}
/>
{/* No-drag carve under the overlay buttons so they stay clickable. */}
<div
className="app-region-no-drag shrink-0"
style={{ width: 'calc(var(--oc-titlebar-controls-width, 5.5rem) + 0.5rem)' }}
/>
{/* Draggable remainder of the strip. */}
<div className="app-region-drag flex-1" />
</div>
);
@@ -0,0 +1,98 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
import { useProjectActionsContext } from '@/hooks/useProjectActionsContext';
import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
const ICON_BUTTON_CLASS =
'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary hover:bg-interactive-hover transition-colors';
/**
* Persistent top-left titlebar controls (sidebar toggle + project actions).
*
* Rendered exactly once as an absolutely-positioned overlay above both the
* sidebar and the header, so the buttons never migrate / re-mount between the
* two while the sidebar animates open or closed the panels slide *underneath*
* a fixed control cluster instead. Its height tracks `--oc-header-height` and
* its left padding clears the OS window controls via `--oc-titlebar-left-inset`.
* The cluster's measured width is published as `--oc-titlebar-controls-width`
* so the header can reserve matching space when the sidebar is collapsed.
*/
export const TitlebarLeftControls: React.FC = () => {
const { t } = useI18n();
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const projectActionsContext = useProjectActionsContext();
const clusterRef = React.useRef<HTMLDivElement | null>(null);
const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides));
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
}
const node = clusterRef.current;
if (!node) {
return;
}
const publishWidth = () => {
const width = node.getBoundingClientRect().width;
document.documentElement.style.setProperty('--oc-titlebar-controls-width', `${Math.round(width)}px`);
};
publishWidth();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(publishWidth);
observer.observe(node);
return () => {
observer.disconnect();
};
}, []);
return (
// The overlay is a CSS no-drag zone so its buttons stay clickable. The
// header / sidebar strip beneath carve a matching no-drag region under it
// and remain drag regions everywhere else, so window dragging still works
// in the empty parts of the strip.
<div
className="app-region-no-drag absolute left-0 top-0 z-30 flex select-none items-center pr-2"
style={{
height: 'var(--oc-header-height, 3rem)',
paddingLeft: 'var(--oc-titlebar-left-inset, 0.75rem)',
}}
>
<div ref={clusterRef} className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={toggleSidebar}
aria-label={t('header.actions.openSessionsAria')}
className={cn(ICON_BUTTON_CLASS, 'shrink-0')}
>
<Icon name="layout-left" className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('header.actions.openSessionsWithShortcut', { shortcut: toggleShortcut })}</p>
</TooltipContent>
</Tooltip>
{projectActionsContext ? (
<ProjectActionsButton
projectRef={projectActionsContext.projectRef}
directory={projectActionsContext.directory}
/>
) : null}
</div>
</div>
);
};
@@ -260,7 +260,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return (
<header
className={cn(
'flex items-center gap-3 border-b border-[var(--interactive-border)] bg-sidebar pr-3',
'flex items-center gap-3 bg-background pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
isWindowsElectronDesktop ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)}
+3 -2
View File
@@ -2361,7 +2361,7 @@ export const GitView: React.FC = () => {
}
return (
<div className={cn('flex h-full flex-col overflow-hidden', 'bg-sidebar')}>
<div className={cn('flex h-full flex-col overflow-hidden')}>
<GitHeader
status={status}
localBranches={localBranches}
@@ -2407,7 +2407,7 @@ export const GitView: React.FC = () => {
<div className="flex-1 min-h-0 overflow-hidden">
<div className="h-full min-h-0 flex flex-col">
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', 'bg-sidebar')}>
<div className={cn('min-w-0 min-h-0 h-full flex flex-col')}>
<ScrollableOverlay
as={ScrollShadow}
ref={actionPanelScrollRef}
@@ -2429,6 +2429,7 @@ export const GitView: React.FC = () => {
onVisiblePathsChange={setVisibleChangePaths}
onRevertAll={handleRevertAll}
onRevertDirectory={handleRevertDirectory}
headerBackgroundClassName="bg-background"
/>
</div>
@@ -118,7 +118,7 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
return (
<div
className={`group flex items-center gap-2 py-1.5 hover:bg-sidebar/40 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
className={`group flex items-center gap-2 py-1.5 cursor-pointer ${rowPaddingClassName ?? 'px-3'}`}
role="button"
tabIndex={0}
onClick={onViewDiff}
@@ -357,7 +357,7 @@ export const ChangesPanel: React.FC<ChangesPanelProps> = ({
const isDirectoryReverting = isRevertingAll || directoryPaths.some((path) => revertingPaths.has(path));
return (
<div
className={cn('group flex items-center gap-2 py-1.5 hover:bg-sidebar/40', ROW_PADDING_CLASSNAME)}
className={cn('group flex items-center gap-2 py-1.5', ROW_PADDING_CLASSNAME)}
style={{ paddingLeft: `${depth * TREE_INDENT_PX}px` }}
>
<button
@@ -0,0 +1,77 @@
import React from 'react';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSession } from '@/sync/sync-context';
import type { ProjectRef } from '@/lib/openchamberConfig';
export interface ProjectActionsContext {
projectRef: ProjectRef;
directory: string;
}
const normalize = (value: string): string => {
if (!value) return '';
const replaced = value.replace(/\\/g, '/');
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
};
/**
* Resolves the active project ref + working directory used by
* {@link ProjectActionsButton}. Directory priority mirrors the header:
* worktree session draft project path. A sticky ref keeps the last
* good context so the actions button doesn't flicker during session switches.
*/
export function useProjectActionsContext(): ProjectActionsContext | null {
const activeProject = useProjectsStore((state) => {
if (!state.activeProjectId) {
return null;
}
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
});
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSession = useSession(currentSessionId ?? null);
const worktreePath = useSessionUIStore((state) => {
if (!currentSessionId) return '';
return state.worktreeMetadata.get(currentSessionId)?.path ?? '';
});
const draftDirectory = useSessionUIStore((state) => {
if (!state.newSessionDraft?.open) {
return '';
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const worktreeDirectory = React.useMemo(() => normalize(worktreePath || ''), [worktreePath]);
const sessionDirectory = React.useMemo(() => {
const raw = typeof currentSession?.directory === 'string' ? currentSession.directory : '';
return normalize(raw || '');
}, [currentSession?.directory]);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory;
const actionDirectory = React.useMemo(
() => normalize(openDirectory || activeProject?.path || ''),
[activeProject?.path, openDirectory],
);
const activeProjectRef = React.useMemo<ProjectRef | null>(() => {
if (!activeProject) {
return null;
}
return { id: activeProject.id, path: activeProject.path };
}, [activeProject]);
const lastContextRef = React.useRef<ProjectActionsContext | null>(null);
React.useEffect(() => {
if (activeProjectRef && actionDirectory) {
lastContextRef.current = { projectRef: activeProjectRef, directory: actionDirectory };
}
}, [actionDirectory, activeProjectRef]);
return React.useMemo(() => {
if (activeProjectRef && actionDirectory) {
return { projectRef: activeProjectRef, directory: actionDirectory };
}
return lastContextRef.current;
}, [activeProjectRef, actionDirectory]);
}