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 <makeittech@users.noreply.github.com>

* 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 <makeittech@users.noreply.github.com>

* 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 <makeittech@users.noreply.github.com>

---------
Authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-07-24 21:27:38 +03:00
committed by GitHub
parent ea157c9aa8
commit 484fe8bc18
7 changed files with 207 additions and 200 deletions
@@ -48,10 +48,19 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
return null; 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'; // Left-side controls sit in the same cluster as h-8 titlebar icon buttons
const containerClassName = position === 'left' // (app menu / sidebar / project actions). Match that size and avoid negative
? 'app-region-no-drag -ml-3 mr-2 flex h-12 shrink-0 items-center' // margins so TitlebarLeftControls can publish an accurate reserved width —
: 'app-region-no-drag -mr-3 ml-2 flex h-12 shrink-0 items-center'; // 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 ( return (
<div className={containerClassName} aria-label={t('header.windowControls.groupAria')}> <div className={containerClassName} aria-label={t('header.windowControls.groupAria')}>
@@ -56,7 +56,9 @@ export const TitlebarLeftControls: React.FC = () => {
} }
const publishWidth = () => { 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`); document.documentElement.style.setProperty('--oc-titlebar-controls-width', `${Math.round(width)}px`);
}; };
@@ -14,40 +14,24 @@ import {
setDesktopKeepAwake, setDesktopKeepAwake,
setDesktopLaunchAtLogin, setDesktopLaunchAtLogin,
setDesktopMinimizeToTray, setDesktopMinimizeToTray,
usesFramelessElectronChrome,
type DesktopWindowControlsPosition,
} from '@/lib/desktop'; } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { updateDesktopSettings } from '@/lib/persistence';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { useUIStore } from '@/stores/useUIStore';
import { import {
SettingsSection, SettingsSection,
SettingsCheckboxRow, SettingsCheckboxRow,
SettingsChipGroup,
SettingsFieldRow,
SETTINGS_OPTION_STACK_CLASS, SETTINGS_OPTION_STACK_CLASS,
SettingsStackedField, SettingsStackedField,
SETTINGS_ICON_BUTTON_CLASS, SETTINGS_ICON_BUTTON_CLASS,
} from '@/components/sections/shared/SettingsSection'; } 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 = () => { export const DesktopNetworkSettings: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const tUnsafe = React.useCallback((key: string) => t(key as Parameters<typeof t>[0]), [t]);
const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive(); const isLocalDesktop = isDesktopShell() && isDesktopLocalOriginActive();
const isMacDesktop = isLocalDesktop const isMacDesktop = isLocalDesktop
&& typeof window !== 'undefined' && typeof window !== 'undefined'
&& window.__OPENCHAMBER_PLATFORM__ === 'darwin'; && 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 [savedValue, setSavedValue] = React.useState(false);
const [draftValue, setDraftValue] = React.useState(false); const [draftValue, setDraftValue] = React.useState(false);
const [savedPassword, setSavedPassword] = React.useState(''); 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 () => { const handleLaunchAtLoginToggle = React.useCallback(async () => {
if (!launchAtLoginSupported || isSavingLaunchAtLogin) { if (!launchAtLoginSupported || isSavingLaunchAtLogin) {
return; return;
@@ -362,34 +341,11 @@ export const DesktopNetworkSettings: React.FC = () => {
} }
}, [draftMacMenuBarEnabled, draftPassword, draftValue, isDirty, t]); }, [draftMacMenuBarEnabled, draftPassword, draftValue, isDirty, t]);
if (!isLocalDesktop && !showWindowControlsPosition) { if (!isLocalDesktop) {
return null; return null;
} }
return ( return (
<>
{showWindowControlsPosition ? (
<SettingsSection title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}>
<SettingsFieldRow
settingsItem="sessions.desktop-window-controls-position"
label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
alignEnd={false}
controlClassName="flex-col items-stretch"
>
<SettingsChipGroup
value={desktopWindowControlsPosition}
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
value: option.id,
label: tUnsafe(option.labelKey),
}))}
onChange={handleWindowControlsPositionChange}
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
/>
</SettingsFieldRow>
</SettingsSection>
) : null}
{isLocalDesktop ? (
<SettingsSection title={t('settings.openchamber.desktopNetwork.title')}> <SettingsSection title={t('settings.openchamber.desktopNetwork.title')}>
<div className="space-y-3"> <div className="space-y-3">
{(launchAtLoginSupported || isMacDesktop || minimizeToTraySupported || keepAwakeSupported) ? ( {(launchAtLoginSupported || isMacDesktop || minimizeToTraySupported || keepAwakeSupported) ? (
@@ -536,7 +492,5 @@ export const DesktopNetworkSettings: React.FC = () => {
</div> </div>
</div> </div>
</SettingsSection> </SettingsSection>
) : null}
</>
); );
}; };
@@ -14,7 +14,7 @@ import { DesktopNetworkSettings } from './DesktopNetworkSettings';
import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings'; import { KeyboardShortcutsSettings } from './KeyboardShortcutsSettings';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { useDeviceInfo } from '@/lib/device'; 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 { isCapacitorApp } from '@/lib/platform';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -42,7 +42,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
const showAbout = isMobile && isWebRuntime(); const showAbout = isMobile && isWebRuntime();
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
void runtimeEndpointEpoch; void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
// If no section specified, show all (mobile/legacy behavior) // If no section specified, show all (mobile/legacy behavior)
if (!section) { if (!section) {
@@ -135,7 +135,7 @@ const GeneralSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const runtimeEndpointEpoch = useRuntimeEndpointEpoch(); const runtimeEndpointEpoch = useRuntimeEndpointEpoch();
void runtimeEndpointEpoch; void runtimeEndpointEpoch;
const showDesktopNetworkSettings = isDesktopShell() && (isDesktopLocalOriginActive() || usesFramelessElectronChrome()); const showDesktopNetworkSettings = isDesktopShell() && isDesktopLocalOriginActive();
// Passkeys only work against the browser's WebAuthn UI on the web surface — // 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. // desktop shell, VS Code, and the Capacitor app never show the login screen.
const showPasskeySettings = isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp(); const showPasskeySettings = isWebRuntime() && !isDesktopShell() && !isVSCode && !isCapacitorApp();
@@ -162,6 +162,7 @@ const VisualSectionContent: React.FC = () => {
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
return <OpenChamberVisualSettings visibleSettings={[ return <OpenChamberVisualSettings visibleSettings={[
'theme', 'theme',
'windowControlsPosition',
'pwaInstallName', 'pwaInstallName',
'pwaOrientation', 'pwaOrientation',
'mobileKeyboardMode', 'mobileKeyboardMode',
@@ -17,7 +17,14 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Icon } from "@/components/icon/Icon"; import { Icon } from "@/components/icon/Icon";
import { invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import {
invokeDesktop,
isDesktopShell,
isVSCodeRuntime,
isWebRuntime,
usesFramelessElectronChrome,
type DesktopWindowControlsPosition,
} from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device'; import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection'; import { usePwaDetection } from '@/hooks/usePwaDetection';
import { updateDesktopSettings } from '@/lib/persistence'; import { updateDesktopSettings } from '@/lib/persistence';
@@ -271,7 +278,13 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain'; return mode === 'markdown' ? 'markdown' : 'plain';
}; };
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar';
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' },
];
interface OpenChamberVisualSettingsProps { interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */ /** Which settings to show. If undefined, shows all. */
@@ -408,6 +421,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
); );
const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled); const dockBadgeEnabled = useUIStore(state => state.dockBadgeEnabled);
const setDockBadgeEnabled = useUIStore(state => state.setDockBadgeEnabled); 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 [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
const reportUsage = useUIStore(state => state.reportUsage); const reportUsage = useUIStore(state => state.reportUsage);
const setReportUsage = useUIStore(state => state.setReportUsage); const setReportUsage = useUIStore(state => state.setReportUsage);
@@ -418,6 +434,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ reportUsage: enabled }); void updateDesktopSettings({ reportUsage: enabled });
}, [setReportUsage]); }, [setReportUsage]);
const handleWindowControlsPositionChange = React.useCallback((value: DesktopWindowControlsPosition) => {
setDesktopWindowControlsPosition(value);
void updateDesktopSettings({ desktopWindowControlsPosition: value });
}, [setDesktopWindowControlsPosition]);
const shouldAnimateChatPreview = (isSettingsDialogOpen || isMobile || isVSCodeRuntime()) const shouldAnimateChatPreview = (isSettingsDialogOpen || isMobile || isVSCodeRuntime())
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true); && (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
@@ -596,11 +617,12 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const isVSCode = isVSCodeRuntime(); const isVSCode = isVSCodeRuntime();
const hasThemeSettings = shouldShow('theme') && !isVSCode; const hasThemeSettings = shouldShow('theme') && !isVSCode;
const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart'); const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode; const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode const hasAppearanceSettings = isVSCode
? hasLocalizationSettings ? 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 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 hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || (shouldShow('expandedEditorToolbar') && !isVSCode);
const hasBehaviorSettings = shouldShow('mermaidRendering') const hasBehaviorSettings = shouldShow('mermaidRendering')
@@ -980,6 +1002,25 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection> </SettingsSection>
)} )}
{showWindowControlsPositionSetting && (
<SettingsSection
title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}
description={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
divider={hasThemeSettings}
settingsItem="sessions.desktop-window-controls-position"
>
<SettingsChipGroup
value={desktopWindowControlsPosition}
options={WINDOW_CONTROLS_POSITION_OPTIONS.map((option) => ({
value: option.id,
label: tUnsafe(option.labelKey),
}))}
onChange={handleWindowControlsPositionChange}
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')}
/>
</SettingsSection>
)}
{hasLocalizationSettings && ( {hasLocalizationSettings && (
<SettingsSection title={t('settings.openchamber.visual.section.localization')}> <SettingsSection title={t('settings.openchamber.visual.section.localization')}>
<SettingsTwoColumn> <SettingsTwoColumn>
+2 -2
View File
@@ -246,8 +246,8 @@ export const getElectronPlatform = (): string | null => {
return typeof platform === 'string' ? platform : null; return typeof platform === 'string' ? platform : null;
}; };
/** Width of the three in-app window control buttons (3 × w-11). */ /** Width of the three in-app window control buttons when placed on the left (3 × w-8). */
export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 132; export const DESKTOP_WINDOW_CONTROLS_WIDTH_PX = 96;
/** Windows and Linux use frameless windows with in-app minimize/maximize/close controls. */ /** Windows and Linux use frameless windows with in-app minimize/maximize/close controls. */
export const usesFramelessElectronChrome = (): boolean => { export const usesFramelessElectronChrome = (): boolean => {
+1 -1
View File
@@ -392,7 +392,7 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
}, },
{ {
id: 'sessions.desktop-window-controls-position', id: 'sessions.desktop-window-controls-position',
page: 'general', page: 'appearance',
titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition',
descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription',
keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'], keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'],