2026-06-09 15:51:04 +03:00
|
|
|
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';
|
2026-06-15 14:52:24 +03:00
|
|
|
import { invokeDesktop } from '@/lib/desktop';
|
2026-06-09 15:51:04 +03:00
|
|
|
|
|
|
|
|
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);
|
2026-06-09 18:38:01 +03:00
|
|
|
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
2026-06-09 15:51:04 +03:00
|
|
|
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
|
|
|
|
|
const projectActionsContext = useProjectActionsContext();
|
|
|
|
|
const clusterRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
|
|
|
const toggleShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('toggle_sidebar', shortcutOverrides));
|
2026-06-15 14:52:24 +03:00
|
|
|
const isWindowsElectronDesktop = React.useMemo(() => {
|
|
|
|
|
if (typeof window === 'undefined') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return Boolean(window.__OPENCHAMBER_ELECTRON__) && window.__OPENCHAMBER_PLATFORM__ === 'win32';
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleOpenWindowsAppMenu = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
|
|
|
void invokeDesktop('desktop_show_app_menu', {
|
|
|
|
|
x: rect.left,
|
|
|
|
|
y: rect.bottom,
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
console.warn('[titlebar] failed to open app menu', error);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
2026-06-09 15:51:04 +03:00
|
|
|
|
|
|
|
|
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">
|
2026-06-15 14:52:24 +03:00
|
|
|
{isWindowsElectronDesktop ? (
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleOpenWindowsAppMenu}
|
|
|
|
|
aria-label={t('header.actions.openAppMenuAria')}
|
|
|
|
|
className={cn(ICON_BUTTON_CLASS, 'shrink-0')}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="menu-2" className="h-[18px] w-[18px]" />
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
<p>{t('header.actions.openAppMenu')}</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-09 15:51:04 +03:00
|
|
|
<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}
|
2026-06-09 18:38:01 +03:00
|
|
|
// While the sidebar is open the controls sit over the frosted
|
|
|
|
|
// sidebar — let the pill share its translucency instead of painting
|
|
|
|
|
// an opaque surface (handled under [data-oc-vibrancy] in CSS).
|
|
|
|
|
className={isSidebarOpen ? 'oc-vibrancy-pill' : undefined}
|
2026-06-09 15:51:04 +03:00
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|