Merge pull request #2498 from kydorn/feat/linux-window-controls-style

feat(desktop): gate traffic-lights behind window-controls style setting
This commit is contained in:
Serhii Dziupin
2026-07-30 10:47:39 +03:00
committed by GitHub
18 changed files with 258 additions and 21 deletions
@@ -5,6 +5,71 @@ import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { getDesktopWindowControlsOrder, invokeDesktop } from '@/lib/desktop';
import type { DesktopWindowControlAction, DesktopWindowControlsSide } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
// macOS chrome colors; intentionally theme-independent — these replicate a
// foreign platform's chrome, not OpenChamber's own status tokens, so the
// theme-system hex rule does not apply.
const TRAFFIC_LIGHT_FILL: Record<DesktopWindowControlAction, string> = {
close: '#FF5F57',
minimize: '#FEBC2E',
maximize: '#28C940',
};
// Glyphs are a uniform translucent black, matching macOS — opacity lets the
// fill tint the symbol so it reads on all three fills without per-action colors.
const TRAFFIC_LIGHT_GLYPH = 'rgba(0, 0, 0, 0.7)';
const TrafficLightGlyph: React.FC<{ action: DesktopWindowControlAction }> = ({ action }) => {
if (action === 'close') {
return <Icon name="close" className="size-[10px]" />;
}
if (action === 'minimize') {
return <Icon name="subtract" className="size-[10px]" />;
}
// Green maximize/restore: same `add` (+) glyph for both states. The label
// (restore vs maximize) still flips via isMaximized in TrafficLightButton.
return <Icon name="add" className="size-[10px]" />;
};
type TrafficLightButtonProps = {
action: DesktopWindowControlAction;
isMaximized: boolean;
onActivate: (action: DesktopWindowControlAction) => void;
};
const TrafficLightButton: React.FC<TrafficLightButtonProps> = ({ action, isMaximized, onActivate }) => {
const { t } = useI18n();
const fill = TRAFFIC_LIGHT_FILL[action];
const label =
action === 'close'
? t('header.windowControls.close')
: action === 'minimize'
? t('header.windowControls.minimize')
: isMaximized
? t('header.windowControls.restore')
: t('header.windowControls.maximize');
return (
<button
type="button"
onClick={() => onActivate(action)}
title={label}
aria-label={label}
// 24px-wide button wrapping a 14px circle centers it at a 24px interval
// between neighbors, giving a 10px edge-to-edge gap. The 32px height
// keeps the titlebar's vertical hit band.
className="app-region-no-drag flex h-8 w-[24px] items-center justify-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<span
className="flex size-3.5 items-center justify-center rounded-full shadow-[inset_0_0_0_0.5px_rgba(0,0,0,0.28)] transition-[filter] duration-75 active:brightness-90"
style={{ backgroundColor: fill, color: TRAFFIC_LIGHT_GLYPH }}
>
<span className="flex items-center justify-center opacity-0 transition-opacity duration-75 group-hover/wctl:opacity-100">
<TrafficLightGlyph action={action} />
</span>
</span>
</button>
);
};
type WindowsWindowControlsProps = {
visible: boolean;
@@ -17,6 +82,7 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
}: WindowsWindowControlsProps) {
const { t } = useI18n();
const [isMaximized, setIsMaximized] = React.useState(false);
const desktopWindowControlsStyle = useUIStore((state) => state.desktopWindowControlsStyle);
useEffect(() => {
if (!visible) {
@@ -48,12 +114,50 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
return null;
}
// 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';
// Order is side-driven for both styles: close, minimize, maximize on the
// left; minimize, maximize, close on the right.
const order = getDesktopWindowControlsOrder(position);
const activate = (action: DesktopWindowControlAction) => {
if (action === 'close') {
void invokeDesktop('desktop_close_current_window');
return;
}
if (action === 'minimize') {
void invokeDesktop('desktop_minimize_current_window');
return;
}
void invokeDesktop<{ maximized?: boolean }>('desktop_toggle_current_window_maximized')
.then((state) => setIsMaximized(Boolean(state?.maximized)))
.catch(() => {});
};
// Traffic-light chrome: macOS-style 14px circles in an h-8 band. Glyphs
// reveal on any-cluster hover (group-hover/wctl). TitlebarLeftControls
// measures and republishes the cluster width via ResizeObserver, so the
// narrower footprint takes effect without touching reserved-width constants.
if (desktopWindowControlsStyle === 'traffic-lights') {
return (
<div
className={cn(
'app-region-no-drag group/wctl flex h-8 shrink-0 items-center',
isLeft ? 'mr-1' : 'ml-1',
)}
aria-label={t('header.windowControls.groupAria')}
>
{order.map((action) => (
<TrafficLightButton key={action} action={action} isMaximized={isMaximized} onActivate={activate} />
))}
</div>
);
}
// Classic Windows-style square buttons. Left side matches the h-8 titlebar
// icon cluster (app menu / sidebar / project actions) and avoids negative
// margins so TitlebarLeftControls publishes an accurate reserved width —
// otherwise the project-actions chevron overlaps the session title. Right
// side keeps a taller h-12 Windows-style hit target.
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',
@@ -113,7 +217,7 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
return (
<div className={containerClassName} aria-label={t('header.windowControls.groupAria')}>
{getDesktopWindowControlsOrder(position).map(renderControl)}
{order.map(renderControl)}
</div>
);
});
@@ -24,6 +24,7 @@ import {
isWebRuntime,
usesFramelessElectronChrome,
type DesktopWindowControlsPosition,
type DesktopWindowControlsStyle,
} from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { usePwaDetection } from '@/hooks/usePwaDetection';
@@ -285,6 +286,11 @@ const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPositio
{ id: 'right', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsRight' },
];
const WINDOW_CONTROLS_STYLE_OPTIONS: Array<{ id: DesktopWindowControlsStyle; labelKey: string }> = [
{ id: 'classic', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsClassic' },
{ id: 'traffic-lights', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights' },
];
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
visibleSettings?: VisibleSetting[];
@@ -425,6 +431,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const showWindowControlsPosition = usesFramelessElectronChrome();
const desktopWindowControlsPosition = useUIStore((state) => state.desktopWindowControlsPosition);
const setDesktopWindowControlsPosition = useUIStore((state) => state.setDesktopWindowControlsPosition);
const desktopWindowControlsStyle = useUIStore((state) => state.desktopWindowControlsStyle);
const setDesktopWindowControlsStyle = useUIStore((state) => state.setDesktopWindowControlsStyle);
const [chatRenderPreviewTick, setChatRenderPreviewTick] = React.useState(0);
const reportUsage = useUIStore(state => state.reportUsage);
const setReportUsage = useUIStore(state => state.setReportUsage);
@@ -440,6 +448,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ desktopWindowControlsPosition: value });
}, [setDesktopWindowControlsPosition]);
const handleWindowControlsStyleChange = React.useCallback((value: DesktopWindowControlsStyle) => {
setDesktopWindowControlsStyle(value);
void updateDesktopSettings({ desktopWindowControlsStyle: value });
}, [setDesktopWindowControlsStyle]);
const shouldAnimateChatPreview = (isSettingsDialogOpen || isMobile || isVSCodeRuntime())
&& (visibleSettings ? visibleSettings.includes('chatRenderMode') : true);
@@ -1010,20 +1023,40 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
{showWindowControlsPositionSetting && (
<SettingsSection
title={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}
description={t('settings.openchamber.desktopNetwork.field.windowControlsPositionDescription')}
title={t('settings.openchamber.desktopNetwork.field.windowControls')}
info={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')}
/>
<SettingsTwoColumn>
<SettingsStackedField
label={t('settings.openchamber.desktopNetwork.field.windowControlsPosition')}
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')}
/>
</SettingsStackedField>
<SettingsStackedField
label={t('settings.openchamber.desktopNetwork.field.windowControlsStyle')}
settingsItem="sessions.desktop-window-controls-style"
>
<SettingsChipGroup
value={desktopWindowControlsStyle}
options={WINDOW_CONTROLS_STYLE_OPTIONS.map((option) => ({
value: option.id,
label: tUnsafe(option.labelKey),
}))}
onChange={handleWindowControlsStyleChange}
aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsStyleAria')}
/>
</SettingsStackedField>
</SettingsTwoColumn>
</SettingsSection>
)}
+3 -3
View File
@@ -41,6 +41,8 @@ export type SkillCatalogConfig = {
export type DesktopWindowControlsPosition = 'left' | 'right';
export type DesktopWindowControlsSide = 'left' | 'right';
export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize';
// No fixed-width constant: control width depends on the style (classic vs traffic-lights).
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
export type DesktopSettings = {
themeId?: string;
@@ -142,6 +144,7 @@ export type DesktopSettings = {
pwaOrientation?: 'system' | 'portrait' | 'landscape';
mobileKeyboardMode?: MobileKeyboardMode;
desktopWindowControlsPosition?: DesktopWindowControlsPosition;
desktopWindowControlsStyle?: DesktopWindowControlsStyle;
inputSpellcheckEnabled?: boolean;
showOpenCodeUpdateNotifications?: boolean;
agentControlToolEnabled?: boolean;
@@ -252,9 +255,6 @@ export const getElectronPlatform = (): string | null => {
return typeof platform === 'string' ? platform : null;
};
/** 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;
/** Default side for in-app window controls (Windows-style, right). */
export const DEFAULT_DESKTOP_WINDOW_CONTROLS_POSITION: DesktopWindowControlsPosition = 'right';
@@ -950,6 +950,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Window controls position',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Left',
'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Right',
'settings.openchamber.desktopNetwork.field.windowControls': 'Window controls',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Style',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Window controls style',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': 'Classic',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': 'Traffic lights',
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Start OpenChamber at login',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Start OpenChamber when you log in',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Starts the app in the background without opening a window. Use the desktop status icon to open it.',
@@ -917,6 +917,11 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posición de los controles de ventana",
"settings.openchamber.desktopNetwork.option.windowControlsLeft": "Izquierda",
"settings.openchamber.desktopNetwork.option.windowControlsRight": "Derecha",
"settings.openchamber.desktopNetwork.field.windowControls": "Controles de ventana",
"settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo",
"settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo de los controles de ventana",
"settings.openchamber.desktopNetwork.option.windowControlsClassic": "Clásico",
"settings.openchamber.desktopNetwork.option.windowControlsTrafficLights": "Semáforo",
"settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Iniciar OpenChamber al iniciar sesión",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "Iniciar OpenChamber al iniciar sesión",
"settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Inicia la app en segundo plano sin abrir una ventana. Usa el icono de estado del escritorio para abrirla.",
@@ -838,6 +838,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Position des contrôles de fenêtre',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Gauche',
'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Droite',
'settings.openchamber.desktopNetwork.field.windowControls': 'Contrôles de fenêtre',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Style',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Style des contrôles de fenêtre',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': 'Classique',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': 'Feux de circulation',
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'Démarrez OpenChamber lors de la connexion',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'Démarrez OpenChamber lorsque vous vous connectez',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'Démarre l\'application en arrière-plan sans ouvrir de fenêtre. Utilisez l\'icône d\'état du bureau pour l\'ouvrir.',
@@ -950,6 +950,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'ウィンドウコントロールの位置',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左',
'settings.openchamber.desktopNetwork.option.windowControlsRight': '右',
'settings.openchamber.desktopNetwork.field.windowControls': 'ウィンドウコントロール',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'スタイル',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'ウィンドウコントロールのスタイル',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': 'クラシック',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': 'トラフィックライト',
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': 'ログイン時に OpenChamber を起動',
'settings.openchamber.desktopNetwork.field.launchAtLogin': 'ログイン時に OpenChamber を起動',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。デスクトップのステータスアイコンから開けます。',
@@ -917,6 +917,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '창 컨트롤 위치',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': '왼쪽',
'settings.openchamber.desktopNetwork.option.windowControlsRight': '오른쪽',
'settings.openchamber.desktopNetwork.field.windowControls': '창 컨트롤',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': '스타일',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '창 컨트롤 스타일',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': '클래식',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': '트래픽 라이트',
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': '로그인 시 OpenChamber 시작',
'settings.openchamber.desktopNetwork.field.launchAtLogin': '로그인 시 OpenChamber 시작',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '창을 열지 않고 백그라운드에서 앱을 시작합니다. 데스크톱 상태 아이콘으로 열 수 있습니다.',
@@ -785,6 +785,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Pozycja elementów sterujących oknem',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Lewo',
'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Prawo',
'settings.openchamber.desktopNetwork.field.windowControls': 'Elementy sterujące oknem',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Styl',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Styl elementów sterujących oknem',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': 'Klasyczny',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': 'Sygnalizacja świetlna',
'settings.openchamber.git.changesViewAria': 'Tryb widoku zmian Git',
'settings.openchamber.git.changesViewTitle': 'Widok zmian',
'settings.openchamber.git.enableGitmoji': 'Włącz wybieranie Gitmoji',
@@ -917,6 +917,11 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posição dos controles da janela",
"settings.openchamber.desktopNetwork.option.windowControlsLeft": "Esquerda",
"settings.openchamber.desktopNetwork.option.windowControlsRight": "Direita",
"settings.openchamber.desktopNetwork.field.windowControls": "Controles da janela",
"settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo",
"settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo dos controles da janela",
"settings.openchamber.desktopNetwork.option.windowControlsClassic": "Clássico",
"settings.openchamber.desktopNetwork.option.windowControlsTrafficLights": "Semáforo",
"settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Iniciar o OpenChamber ao fazer login",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "Iniciar o OpenChamber ao fazer login",
"settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Inicia o app em segundo plano sem abrir uma janela. Use o ícone de status da área de trabalho para abrir.",
@@ -917,6 +917,11 @@ export const settingsDict = {
"settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Позиція елементів керування вікном",
"settings.openchamber.desktopNetwork.option.windowControlsLeft": "Зліва",
"settings.openchamber.desktopNetwork.option.windowControlsRight": "Справа",
"settings.openchamber.desktopNetwork.field.windowControls": "Елементи керування вікном",
"settings.openchamber.desktopNetwork.field.windowControlsStyle": "Стиль",
"settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Стиль елементів керування вікном",
"settings.openchamber.desktopNetwork.option.windowControlsClassic": "Класичний",
"settings.openchamber.desktopNetwork.option.windowControlsTrafficLights": "Світлофор",
"settings.openchamber.desktopNetwork.field.launchAtLoginAria": "Запускати OpenChamber під час входу в систему",
"settings.openchamber.desktopNetwork.field.launchAtLogin": "Запускати OpenChamber під час входу в систему",
"settings.openchamber.desktopNetwork.field.launchAtLoginDescription": "Запускає застосунок у фоні без відкриття вікна. Відкрийте його через піктограму стану на робочому столі.",
@@ -917,6 +917,11 @@ export const settingsDict = {
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '窗口控件位置',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左侧',
'settings.openchamber.desktopNetwork.option.windowControlsRight': '右侧',
'settings.openchamber.desktopNetwork.field.windowControls': '窗口控件',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': '样式',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '窗口控件样式',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': '经典',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': '红绿灯',
'settings.openchamber.desktopNetwork.field.launchAtLoginAria': '登录时启动 OpenChamber',
'settings.openchamber.desktopNetwork.field.launchAtLogin': '登录时启动 OpenChamber',
'settings.openchamber.desktopNetwork.field.launchAtLoginDescription': '在后台启动应用且不打开窗口。可通过桌面状态图标打开。',
@@ -914,6 +914,11 @@
'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '視窗控制項位置',
'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左側',
'settings.openchamber.desktopNetwork.option.windowControlsRight': '右側',
'settings.openchamber.desktopNetwork.field.windowControls': '視窗控制項',
'settings.openchamber.desktopNetwork.field.windowControlsStyle': '樣式',
'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '視窗控制項樣式',
'settings.openchamber.desktopNetwork.option.windowControlsClassic': '經典',
'settings.openchamber.desktopNetwork.option.windowControlsTrafficLights': '紅綠燈',
'settings.openchamber.desktopNetwork.field.allowLanAccessAria': '允許桌面 sidecar 區域網路存取',
'settings.openchamber.desktopNetwork.field.allowLanAccess': '允許你本機網路中的其他裝置開啟此應用程式',
'settings.openchamber.desktopNetwork.field.allowLanAccessDescription': '會重新啟動應用程式,以便手機、平板和同一 Wi‑Fi 下的其他電腦存取。',
+16
View File
@@ -557,6 +557,7 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS
timeFormatPreference: defaults.timeFormatPreference,
weekStartPreference: defaults.weekStartPreference,
desktopWindowControlsPosition: defaults.desktopWindowControlsPosition,
desktopWindowControlsStyle: defaults.desktopWindowControlsStyle,
chatRenderMode: defaults.chatRenderMode,
activityRenderMode: defaults.activityRenderMode,
mermaidRenderingMode: defaults.mermaidRenderingMode,
@@ -748,6 +749,16 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
store.setDesktopWindowControlsPosition(nextPosition);
}
}
if (typeof settings.desktopWindowControlsStyle === 'string') {
const nextStyle = settings.desktopWindowControlsStyle === 'traffic-lights'
? 'traffic-lights'
: settings.desktopWindowControlsStyle === 'classic'
? 'classic'
: null;
if (nextStyle && nextStyle !== store.desktopWindowControlsStyle) {
store.setDesktopWindowControlsStyle(nextStyle);
}
}
if (typeof settings.chatRenderMode === 'string'
&& (settings.chatRenderMode === 'sorted' || settings.chatRenderMode === 'live')) {
if (settings.chatRenderMode !== store.chatRenderMode) {
@@ -1379,6 +1390,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
result.desktopWindowControlsPosition = 'right';
}
}
if (typeof candidate.desktopWindowControlsStyle === 'string') {
if (candidate.desktopWindowControlsStyle === 'classic' || candidate.desktopWindowControlsStyle === 'traffic-lights') {
result.desktopWindowControlsStyle = candidate.desktopWindowControlsStyle;
}
}
if (typeof candidate.chatRenderMode === 'string'
&& (candidate.chatRenderMode === 'sorted' || candidate.chatRenderMode === 'live')) {
result.chatRenderMode = candidate.chatRenderMode;
+7
View File
@@ -404,6 +404,13 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'],
isAvailable: (ctx) => ctx.isDesktop && (ctx.isWindows || !ctx.isMac),
},
{
id: 'sessions.desktop-window-controls-style',
page: 'appearance',
titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsStyle',
keywords: ['desktop', 'window', 'controls', 'style', 'traffic', 'lights', 'classic', 'macos', 'titlebar'],
isAvailable: (ctx) => ctx.isDesktop && (ctx.isWindows || !ctx.isMac),
},
{
id: 'sessions.desktop-mac-menu-bar',
page: 'general',
+8
View File
@@ -22,6 +22,7 @@ export type SessionRetentionAction = 'archive' | 'delete';
export type TimeFormatPreference = 'auto' | '12h' | '24h';
export type WeekStartPreference = 'auto' | 'sunday' | 'monday';
export type DesktopWindowControlsPosition = 'left' | 'right';
export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights';
export type FileEditorKeymap = 'default' | 'vim';
function normalizeFileEditorKeymap(value: unknown): FileEditorKeymap {
@@ -693,6 +694,7 @@ interface UIStore {
timeFormatPreference: TimeFormatPreference;
weekStartPreference: WeekStartPreference;
desktopWindowControlsPosition: DesktopWindowControlsPosition;
desktopWindowControlsStyle: DesktopWindowControlsStyle;
mermaidRenderingMode: MermaidRenderingMode;
userMessageRenderingMode: UserMessageRenderingMode;
collapsibleUserMessages: boolean;
@@ -854,6 +856,7 @@ interface UIStore {
setTimeFormatPreference: (value: TimeFormatPreference) => void;
setWeekStartPreference: (value: WeekStartPreference) => void;
setDesktopWindowControlsPosition: (value: DesktopWindowControlsPosition) => void;
setDesktopWindowControlsStyle: (value: DesktopWindowControlsStyle) => void;
setMermaidRenderingMode: (value: MermaidRenderingMode) => void;
setUserMessageRenderingMode: (value: UserMessageRenderingMode) => void;
setCollapsibleUserMessages: (value: boolean) => void;
@@ -1004,6 +1007,7 @@ export const useUIStore = create<UIStore>()(
timeFormatPreference: 'auto',
weekStartPreference: 'auto',
desktopWindowControlsPosition: 'right',
desktopWindowControlsStyle: 'classic',
mermaidRenderingMode: 'svg',
userMessageRenderingMode: 'markdown',
collapsibleUserMessages: true,
@@ -2169,6 +2173,9 @@ export const useUIStore = create<UIStore>()(
setDesktopWindowControlsPosition: (value) => {
set({ desktopWindowControlsPosition: value === 'left' ? 'left' : 'right' });
},
setDesktopWindowControlsStyle: (value) => {
set({ desktopWindowControlsStyle: value === 'traffic-lights' ? 'traffic-lights' : 'classic' });
},
setMermaidRenderingMode: (value) => {
set({ mermaidRenderingMode: value });
},
@@ -2439,6 +2446,7 @@ export const useUIStore = create<UIStore>()(
timeFormatPreference: state.timeFormatPreference,
weekStartPreference: state.weekStartPreference,
desktopWindowControlsPosition: state.desktopWindowControlsPosition,
desktopWindowControlsStyle: state.desktopWindowControlsStyle,
mermaidRenderingMode: state.mermaidRenderingMode,
userMessageRenderingMode: state.userMessageRenderingMode,
collapsibleUserMessages: state.collapsibleUserMessages,
@@ -197,6 +197,12 @@ export const createSettingsHelpers = (dependencies) => {
result.desktopWindowControlsPosition = 'left';
}
}
if (typeof candidate.desktopWindowControlsStyle === 'string') {
const style = candidate.desktopWindowControlsStyle.trim();
if (style === 'classic' || style === 'traffic-lights') {
result.desktopWindowControlsStyle = style;
}
}
if (candidate.permissionAutoAccept && typeof candidate.permissionAutoAccept === 'object' && !Array.isArray(candidate.permissionAutoAccept)) {
const sessions = {};
const sourceSessions = candidate.permissionAutoAccept.sessions;
@@ -169,6 +169,19 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsPosition: 'center' })).toEqual({});
});
it('sanitizes desktopWindowControlsStyle and rejects unknown values', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'classic' })).toEqual({
desktopWindowControlsStyle: 'classic',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'traffic-lights' })).toEqual({
desktopWindowControlsStyle: 'traffic-lights',
});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'macos' })).toEqual({});
expect(helpers.sanitizeSettingsUpdate({ desktopWindowControlsStyle: 'auto' })).toEqual({});
});
it('sanitizes the persisted permission auto-accept policy', () => {
const helpers = createTestHelpers();