From 484fe8bc1859d8f662ca8ca7cbe3818f657a0cf6 Mon Sep 17 00:00:00 2001 From: Serhii Dziupin Date: Fri, 24 Jul 2026 21:27:38 +0300 Subject: [PATCH] fix(ui): move window controls position setting to Appearance (#2406) * fix(ui): move window controls position setting to Appearance Keep desktop chrome layout with other visual settings; General retains startup, tray, network, and access controls. Update settings search page mapping so the control still highlights correctly. Authored-by: Serhii Dziupin * fix(ui): align Linux window controls and stop actions overlap Size left-side window controls to match the titlebar icon row and drop negative margins so the reserved titlebar width stays accurate. This keeps the project-actions chevron from colliding with the New session title. Authored-by: Serhii Dziupin * fix(ui): stack window-controls description above chips Show the Appearance window-controls helper copy full-width under the section title, with Auto/Left/Right chips beneath it. Authored-by: Serhii Dziupin --------- Authored-by: Serhii Dziupin --- .../desktop/WindowsWindowControls.tsx | 17 +- .../layout/TitlebarLeftControls.tsx | 4 +- .../openchamber/DesktopNetworkSettings.tsx | 326 ++++++++---------- .../sections/openchamber/OpenChamberPage.tsx | 7 +- .../openchamber/OpenChamberVisualSettings.tsx | 47 ++- packages/ui/src/lib/desktop.ts | 4 +- packages/ui/src/lib/settings/search.ts | 2 +- 7 files changed, 207 insertions(+), 200 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index fba39e3a..6b423955 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -48,10 +48,19 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({ return null; } - const buttonClassName = 'app-region-no-drag inline-flex h-12 w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'; - const containerClassName = position === 'left' - ? 'app-region-no-drag -ml-3 mr-2 flex h-12 shrink-0 items-center' - : 'app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center'; + // Left-side controls sit in the same cluster as h-8 titlebar icon buttons + // (app menu / sidebar / project actions). Match that size and avoid negative + // margins so TitlebarLeftControls can publish an accurate reserved width — + // otherwise the project-actions chevron overlaps the session title. + // Right-side controls keep a taller Windows-style hit target. + const isLeft = position === 'left'; + const buttonClassName = cn( + 'app-region-no-drag inline-flex items-center justify-center text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary', + isLeft ? 'h-8 w-8 rounded-md' : 'h-12 w-11', + ); + const containerClassName = isLeft + ? 'app-region-no-drag mr-1 flex h-8 shrink-0 items-center' + : 'app-region-no-drag ml-1 flex h-12 shrink-0 items-center'; return (
diff --git a/packages/ui/src/components/layout/TitlebarLeftControls.tsx b/packages/ui/src/components/layout/TitlebarLeftControls.tsx index 3b12d2ae..01f4e34f 100644 --- a/packages/ui/src/components/layout/TitlebarLeftControls.tsx +++ b/packages/ui/src/components/layout/TitlebarLeftControls.tsx @@ -56,7 +56,9 @@ export const TitlebarLeftControls: React.FC = () => { } const publishWidth = () => { - const width = node.getBoundingClientRect().width; + // Prefer scrollWidth so negative child margins / overflow cannot under-report + // the space the overlay actually occupies over the header. + const width = Math.max(node.getBoundingClientRect().width, node.scrollWidth); document.documentElement.style.setProperty('--oc-titlebar-controls-width', `${Math.round(width)}px`); }; diff --git a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx index e537c445..9eba0d71 100644 --- a/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DesktopNetworkSettings.tsx @@ -14,40 +14,24 @@ import { setDesktopKeepAwake, setDesktopLaunchAtLogin, setDesktopMinimizeToTray, - usesFramelessElectronChrome, - type DesktopWindowControlsPosition, } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; -import { updateDesktopSettings } from '@/lib/persistence'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; -import { useUIStore } from '@/stores/useUIStore'; import { SettingsSection, SettingsCheckboxRow, - SettingsChipGroup, - SettingsFieldRow, SETTINGS_OPTION_STACK_CLASS, SettingsStackedField, SETTINGS_ICON_BUTTON_CLASS, } from '@/components/sections/shared/SettingsSection'; -const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ - { id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' }, - { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, - { id: 'right', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsRight' }, -]; - export const DesktopNetworkSettings: React.FC = () => { const { t } = useI18n(); - const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive(); const isMacDesktop = isLocalDesktop && typeof window !== 'undefined' && window.__OPENCHAMBER_PLATFORM__ === 'darwin'; - const showWindowControlsPosition = usesFramelessElectronChrome(); - const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition); - const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition); const [savedValue, setSavedValue] = React.useState(false); const [draftValue, setDraftValue] = React.useState(false); const [savedPassword, setSavedPassword] = React.useState(''); @@ -242,11 +226,6 @@ export const DesktopNetworkSettings: React.FC = () => { } }, []); - const handleWindowControlsPositionChange = React.useCallback((value: DesktopWindowControlsPosition) => { - setDesktopWindowControlsPosition(value); - void updateDesktopSettings({ desktopWindowControlsPosition: value }); - }, [setDesktopWindowControlsPosition]); - const handleLaunchAtLoginToggle = React.useCallback(async () => { if (!launchAtLoginSupported || isSavingLaunchAtLogin) { return; @@ -362,181 +341,156 @@ export const DesktopNetworkSettings: React.FC = () => { } }, [draftMacMenuBarEnabled, draftPassword, draftValue, isDirty, t]); - if (!isLocalDesktop && !showWindowControlsPosition) { + if (!isLocalDesktop) { return null; } return ( - <> - {showWindowControlsPosition ? ( - - - ({ - value: option.id, - label: tUnsafe(option.labelKey), - }))} - onChange={handleWindowControlsPositionChange} - aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')} - /> - - - ) : null} - - {isLocalDesktop ? ( - -
- {(launchAtLoginSupported || isMacDesktop || minimizeToTraySupported || keepAwakeSupported) ? ( -
- {launchAtLoginSupported ? ( - { - if (checked === launchAtLoginEnabled) return; - void handleLaunchAtLoginToggle(); - }} - disabled={isSavingLaunchAtLogin} - label={t('settings.openchamber.desktopNetwork.field.launchAtLogin')} - info={t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')} - ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')} - /> - ) : null} - - {isMacDesktop ? ( - - ) : null} - - {minimizeToTraySupported ? ( - { - if (checked === minimizeToTrayEnabled) return; - void handleMinimizeToTrayToggle(); - }} - disabled={isSavingMinimizeToTray} - label={t('settings.openchamber.desktopNetwork.field.minimizeToTray')} - info={t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')} - ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')} - /> - ) : null} - - {keepAwakeSupported ? ( - { - if (checked === keepAwakeEnabled) return; - void handleKeepAwakeToggle(); - }} - disabled={isSavingKeepAwake} - label={t('settings.openchamber.desktopNetwork.field.keepAwake')} - info={t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')} - ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')} - /> - ) : null} -
- ) : null} - - - {t('settings.openchamber.desktopPassword.field.password')} - - )} - info={t('settings.openchamber.desktopPassword.field.passwordDescription')} - > - handlePasswordChange(event.target.value)} - placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')} - disabled={isLoading || isSaving} - required={draftValue} - aria-invalid={lanRequiresPassword} - /> - - - -
+ +
+ {(launchAtLoginSupported || isMacDesktop || minimizeToTraySupported || keepAwakeSupported) ? ( +
+ {launchAtLoginSupported ? ( - - {t('settings.openchamber.desktopNetwork.field.warning')} - - {lanRequiresPassword || lanBlockedByMissingPassword ? ( - - {t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')} - - ) : null} - - )} - ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')} + settingsItem="sessions.desktop-launch-at-login" + checked={launchAtLoginEnabled} + onChange={(checked) => { + if (checked === launchAtLoginEnabled) return; + void handleLaunchAtLoginToggle(); + }} + disabled={isSavingLaunchAtLogin} + label={t('settings.openchamber.desktopNetwork.field.launchAtLogin')} + info={t('settings.openchamber.desktopNetwork.field.launchAtLoginDescription')} + ariaLabel={t('settings.openchamber.desktopNetwork.field.launchAtLoginAria')} /> -
- - {error ? ( -
{error}
) : null} - {lanUrl ? ( -
- {isDirty && !savedValue - ? t('settings.openchamber.desktopNetwork.hint.openAfterRestart') - : t('settings.openchamber.desktopNetwork.hint.openNow')} - {lanUrl} -
+ {isMacDesktop ? ( + ) : null} -
- -
+ {minimizeToTraySupported ? ( + { + if (checked === minimizeToTrayEnabled) return; + void handleMinimizeToTrayToggle(); + }} + disabled={isSavingMinimizeToTray} + label={t('settings.openchamber.desktopNetwork.field.minimizeToTray')} + info={t('settings.openchamber.desktopNetwork.field.minimizeToTrayDescription')} + ariaLabel={t('settings.openchamber.desktopNetwork.field.minimizeToTrayAria')} + /> + ) : null} + + {keepAwakeSupported ? ( + { + if (checked === keepAwakeEnabled) return; + void handleKeepAwakeToggle(); + }} + disabled={isSavingKeepAwake} + label={t('settings.openchamber.desktopNetwork.field.keepAwake')} + info={t('settings.openchamber.desktopNetwork.field.keepAwakeDescription')} + ariaLabel={t('settings.openchamber.desktopNetwork.field.keepAwakeAria')} + /> + ) : null}
-
- ) : null} - + ) : null} + + + {t('settings.openchamber.desktopPassword.field.password')} + + )} + info={t('settings.openchamber.desktopPassword.field.passwordDescription')} + > + handlePasswordChange(event.target.value)} + placeholder={t('settings.openchamber.desktopPassword.field.passwordPlaceholder')} + disabled={isLoading || isSaving} + required={draftValue} + aria-invalid={lanRequiresPassword} + /> + + + +
+ + + {t('settings.openchamber.desktopNetwork.field.warning')} + + {lanRequiresPassword || lanBlockedByMissingPassword ? ( + + {t('settings.openchamber.desktopNetwork.field.passwordRequiredWarning')} + + ) : null} + + )} + ariaLabel={t('settings.openchamber.desktopNetwork.field.allowLanAccessAria')} + /> +
+ + {error ? ( +
{error}
+ ) : null} + + {lanUrl ? ( +
+ {isDirty && !savedValue + ? t('settings.openchamber.desktopNetwork.hint.openAfterRestart') + : t('settings.openchamber.desktopNetwork.hint.openNow')} + {lanUrl} +
+ ) : null} + +
+ +
+
+ ); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 9698a491..ebae3dc5 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -14,7 +14,7 @@ import { DesktopNetworkSettings } from './DesktopNetworkSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { useDeviceInfo } from '@/lib/device'; -import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime, usesFramelessElectronChrome } from '@/lib/desktop'; +import { isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { isCapacitorApp } from '@/lib/platform'; import { useI18n } from '@/lib/i18n'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; @@ -42,7 +42,7 @@ export const OpenChamberPage: React.FC = ({ section }) => const showAbout = isMobile && isWebRuntime(); const isVSCode = isVSCodeRuntime(); void runtimeEndpointEpoch; - const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); + const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); // If no section specified, show all (mobile/legacy behavior) if (!section) { @@ -135,7 +135,7 @@ const GeneralSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); const runtimeEndpointEpoch = useRuntimeEndpointEpoch(); void runtimeEndpointEpoch; - const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); + const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive(); // Passkeys only work against the browser's WebAuthn UI on the web surface — // desktop shell, VS Code, and the Capacitor app never show the login screen. const showPasskeySettings = isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp(); @@ -162,6 +162,7 @@ const VisualSectionContent: React.FC = () => { const isVSCode = isVSCodeRuntime(); return = [ + { id: 'auto', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsAuto' }, + { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, + { id: 'right', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsRight' }, +]; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -408,6 +421,9 @@ export const OpenChamberVisualSettings: React.FC ); const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled); const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled); + const showWindowControlsPosition = usesFramelessElectronChrome(); + const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition); + const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition); const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0); const reportUsage = useUIStore(state => state.reportUsage); const setReportUsage = useUIStore(state => state.setReportUsage); @@ -418,6 +434,11 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ reportUsage: enabled }); }, [setReportUsage]); + const handleWindowControlsPositionChange = React.useCallback((value: DesktopWindowControlsPosition) => { + setDesktopWindowControlsPosition(value); + void updateDesktopSettings({ desktopWindowControlsPosition: value }); + }, [setDesktopWindowControlsPosition]); + const shouldAnimateChatPreview = (isSettingsDialogOpen || isMobile || isVSCodeRuntime()) && (visibleSettings ? visibleSettings.includes('chatRenderMode') : true); @@ -596,11 +617,12 @@ export const OpenChamberVisualSettings: React.FC const isVSCode = isVSCodeRuntime(); const hasThemeSettings = shouldShow('theme') && !isVSCode; + const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition; const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode; const hasAppearanceSettings = isVSCode ? hasLocalizationSettings - : (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); + : (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile); const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || (shouldShow('expandedEditorToolbar') && !isVSCode); const hasBehaviorSettings = shouldShow('mermaidRendering') @@ -980,6 +1002,25 @@ export const OpenChamberVisualSettings: React.FC )} + {showWindowControlsPositionSetting && ( + + ({ + value: option.id, + label: tUnsafe(option.labelKey), + }))} + onChange={handleWindowControlsPositionChange} + aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')} + /> + + )} + {hasLocalizationSettings && ( diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 1c2ab633..a331c140 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -246,8 +246,8 @@ export const getElectronPlatform = (): string | null => { return typeof platform === 'string' ? platform : null; }; -/** Width of the three in-app window control buttons (3 × w-11). */ -export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 132; +/** Width of the three in-app window control buttons when placed on the left (3 × w-8). */ +export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 96; /** Windows and Linux use frameless windows with in-app minimize/maximize/close controls. */ export const usesFramelessElectronChrome = (): boolean => { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 92d932d1..ef54588d 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -392,7 +392,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ }, { id: 'sessions.desktop-window-controls-position', - page: 'general', + page: 'appearance', titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'],