From 029f1705db5164e5e5d088085f0e2a63d6290b5f Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Wed, 29 Jul 2026 00:07:04 +0200 Subject: [PATCH 1/5] feat(desktop): gate traffic-lights behind window-controls style setting --- .../desktop/WindowsWindowControls.tsx | 125 +++++++++++++++++- .../openchamber/OpenChamberVisualSettings.tsx | 62 +++++++-- packages/ui/src/index.css | 11 ++ packages/ui/src/lib/desktop.ts | 36 ++++- .../ui/src/lib/desktopWindowControls.test.ts | 22 +++ .../ui/src/lib/i18n/messages/en.settings.ts | 9 +- .../ui/src/lib/i18n/messages/es.settings.ts | 9 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 9 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 9 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 9 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 9 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 9 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 9 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 9 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 9 +- packages/ui/src/lib/persistence.ts | 16 +++ packages/ui/src/lib/settings/search.ts | 13 +- packages/ui/src/main.tsx | 2 + packages/ui/src/stores/useUIStore.ts | 19 ++- .../server/lib/opencode/settings-helpers.js | 6 + .../lib/opencode/settings-helpers.test.js | 13 ++ 21 files changed, 370 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 900d5a70..7c5e8bcd 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -5,6 +5,80 @@ 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_COLORS: Record = { + close: { fill: '#FF5F57', glyph: '#7F0808' }, + minimize: { fill: '#FEBC2E', glyph: '#B3710C' }, + maximize: { fill: '#28C940', glyph: '#006200' }, +}; + +const TrafficLightGlyph: React.FC<{ action: DesktopWindowControlAction }> = ({ action }) => { + if (action === 'close') { + return ( + + + + ); + } + if (action === 'minimize') { + return ( + + + + ); + } + // Green expand: two paired corner triangles pointing diagonally outward. + return ( + + + + + ); +}; + +type TrafficLightButtonProps = { + action: DesktopWindowControlAction; + isMaximized: boolean; + onActivate: (action: DesktopWindowControlAction) => void; +}; + +const TrafficLightButton: React.FC = ({ action, isMaximized, onActivate }) => { + const { t } = useI18n(); + const { fill, glyph } = TRAFFIC_LIGHT_COLORS[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 ( + + ); +}; type WindowsWindowControlsProps = { visible: boolean; @@ -17,6 +91,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 +123,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 ( +
+ {order.map((action) => ( + + ))} +
+ ); + } + + // 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 +226,7 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({ return (
- {getDesktopWindowControlsOrder(position).map(renderControl)} + {order.map(renderControl)}
); }); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index c28f2b70..bd2b395e 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -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 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 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); @@ -738,6 +751,10 @@ export const OpenChamberVisualSettings: React.FC const option = MOBILE_KEYBOARD_MODE_OPTIONS.find((item) => item.id === mobileKeyboardMode); return option ? tUnsafe(option.labelKey) : undefined; }, [mobileKeyboardMode, tUnsafe]); + const selectedWindowControlsStyleLabel = React.useMemo(() => { + const option = WINDOW_CONTROLS_STYLE_OPTIONS.find((item) => item.id === desktopWindowControlsStyle); + return option ? tUnsafe(option.labelKey) : undefined; + }, [desktopWindowControlsStyle, tUnsafe]); const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => { if (value === mobileLayoutPreference) { @@ -1010,20 +1027,41 @@ export const OpenChamberVisualSettings: React.FC {showWindowControlsPositionSetting && ( - ({ - value: option.id, - label: tUnsafe(option.labelKey), - }))} - onChange={handleWindowControlsPositionChange} - aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')} - /> + + + ({ + value: option.id, + label: tUnsafe(option.labelKey), + }))} + onChange={handleWindowControlsPositionChange} + aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsPositionAria')} + /> + + + + + )} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 347533fc..11a59191 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -71,6 +71,17 @@ html[data-oc-vibrancy] .main-content-safe-area { background-color: transparent !important; } +/* ── Linux frameless window border ──────────────────────────────────────── + `data-oc-window-border` is set on by initLinuxWindowBorder() + (ui/src/lib/desktop.ts), and `data-oc-window-maximized` drops the border + so a maximized window stays flush with screen edges. Rounded corners + were removed: Electron 41's transparent-window path on Linux is unreliable + without --disable-gpu, and native rounded corners landed upstream only in + Electron 43 (PR #51459). */ +html[data-oc-window-border]:not([data-oc-window-maximized]) #root { + border: 1px solid var(--border); +} + /* Left sidebar: solid until the native layer is ready, then a translucent overlay so the vibrancy frosts through. */ html[data-oc-vibrancy] .oc-vibrancy-surface { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index b0b895a7..10194028 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -41,6 +41,7 @@ export type SkillCatalogConfig = { export type DesktopWindowControlsPosition = 'left' | 'right'; export type DesktopWindowControlsSide = 'left' | 'right'; export type DesktopWindowControlAction = 'close' | 'minimize' | 'maximize'; +export type DesktopWindowControlsStyle = 'classic' | 'traffic-lights'; export type DesktopSettings = { themeId?: string; @@ -142,6 +143,7 @@ export type DesktopSettings = { pwaOrientation?: 'system' | 'portrait' | 'landscape'; mobileKeyboardMode?: MobileKeyboardMode; desktopWindowControlsPosition?: DesktopWindowControlsPosition; + desktopWindowControlsStyle?: DesktopWindowControlsStyle; inputSpellcheckEnabled?: boolean; showOpenCodeUpdateNotifications?: boolean; agentControlToolEnabled?: boolean; @@ -252,9 +254,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'; @@ -301,6 +300,37 @@ export const hasDesktopInvoke = (): boolean => { return typeof getDesktopBridge()?.invoke === 'function'; }; +/** + * Linux frameless Electron windows show a 1px CSS border on #root (see + * index.css). This function flips the attribute on so the rule + * matches, and tracks `data-oc-window-maximized` so the border drops when + * maximized (a 1px line inset from every screen edge would look off). + * No-op outside Linux frameless Electron. + */ +export const initLinuxWindowBorder = (): void => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + if (!usesFramelessElectronChrome()) return; + const platform = getElectronPlatform(); + if (platform !== 'linux') return; + + const root = document.documentElement; + root.setAttribute('data-oc-window-border', ''); + + const applyMaximized = (maximized: boolean) => { + root.toggleAttribute('data-oc-window-maximized', maximized); + }; + + void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state') + .then((state) => applyMaximized(Boolean(state?.maximized))) + .catch(() => {}); + + const onMaximizedChange = (event: Event) => { + const detail = (event as CustomEvent<{ maximized?: boolean }>).detail; + applyMaximized(Boolean(detail?.maximized)); + }; + window.addEventListener('openchamber:window-maximized-changed', onMaximizedChange); +}; + export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke(); export const invokeDesktop = async (command: string, args?: Record): Promise => { diff --git a/packages/ui/src/lib/desktopWindowControls.test.ts b/packages/ui/src/lib/desktopWindowControls.test.ts index a38e0159..2a8237a9 100644 --- a/packages/ui/src/lib/desktopWindowControls.test.ts +++ b/packages/ui/src/lib/desktopWindowControls.test.ts @@ -6,6 +6,7 @@ import { normalizeDesktopWindowControlsPosition, resolveDesktopWindowControlsSide, } from './desktop'; +import { useUIStore } from '@/stores/useUIStore'; describe('desktop window controls position', () => { test('defaults to right', () => { @@ -30,3 +31,24 @@ describe('desktop window controls position', () => { expect(getDesktopWindowControlsOrder('right')).toEqual(['minimize', 'maximize', 'close']); }); }); + +describe('useUIStore v12→v13 migration', () => { + test('introduces classic style while preserving position', () => { + const migrate = useUIStore.persist.getOptions().migrate!; + const result = migrate({ desktopWindowControlsPosition: 'left' }, 12) as Record; + expect(result.desktopWindowControlsPosition).toBe('left'); + expect(result.desktopWindowControlsStyle).toBe('classic'); + }); + + test('does not overwrite a persisted style', () => { + const migrate = useUIStore.persist.getOptions().migrate!; + const result = migrate({ desktopWindowControlsStyle: 'traffic-lights' }, 12) as Record; + expect(result.desktopWindowControlsStyle).toBe('traffic-lights'); + }); + + test('coerces an invalid persisted style to classic', () => { + const migrate = useUIStore.persist.getOptions().migrate!; + const result = migrate({ desktopWindowControlsStyle: 'macos' }, 12) as Record; + expect(result.desktopWindowControlsStyle).toBe('classic'); + }); +}); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 0f2e9be7..99c80a86 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -945,11 +945,16 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Failed to archive {count} session(s)', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Failed to delete {count} session(s)', 'settings.openchamber.desktopNetwork.title': 'Desktop Network Access', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Window controls position', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choose where minimize, maximize, and close buttons appear. Defaults to the right.', + 'settings.openchamber.desktopNetwork.field.windowControls': 'Window controls', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Choose where window controls appear and how they look.', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Window controls position', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Style', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Window controls style', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Left', 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Right', + '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.', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 68538aab..8b3505b7 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -912,11 +912,16 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "No se pudo archivar {count} sesión(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "No se pudo eliminar {count} sesión(es)", "settings.openchamber.desktopNetwork.title": "Acceso de red de escritorio", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posición de los controles de ventana", - "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Elige dónde aparecen los botones de minimizar, maximizar y cerrar. Por defecto a la derecha.", + "settings.openchamber.desktopNetwork.field.windowControls": "Controles de ventana", + "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Elige dónde aparecen los controles de ventana y su apariencia.", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posición", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posición de los controles de ventana", + "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo", + "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo de los controles de ventana", "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Izquierda", "settings.openchamber.desktopNetwork.option.windowControlsRight": "Derecha", + "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.", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index e1fdd8c5..dcd67aa8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -833,11 +833,16 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Échec de l\'archivage des sessions {count}', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Échec de la suppression des sessions {count}', 'settings.openchamber.desktopNetwork.title': 'Accès au réseau de bureau', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position des contrôles de fenêtre', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choisissez où apparaissent les boutons Réduire, Agrandir et Fermer. Par défaut à droite.', + 'settings.openchamber.desktopNetwork.field.windowControls': 'Contrôles de fenêtre', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Choisissez où apparaissent les contrôles de fenêtre et leur apparence.', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Position des 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.windowControlsLeft': 'Gauche', 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Droite', + '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.', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index fd95fe94..5043efa3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -945,11 +945,16 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました', 'settings.openchamber.desktopNetwork.title': 'Desktop ネットワークアクセス', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'ウィンドウコントロールの位置', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '最小化・最大化・閉じるボタンの表示位置を選びます。デフォルトは右側です。', + 'settings.openchamber.desktopNetwork.field.windowControls': 'ウィンドウコントロール', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'ウィンドウコントロールの表示位置とスタイルを選びます。', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'ウィンドウコントロールの位置', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'スタイル', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'ウィンドウコントロールのスタイル', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左', 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右', + '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': 'ウィンドウを開かずにバックグラウンドでアプリを起動します。デスクトップのステータスアイコンから開けます。', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index d8de884a..54841d45 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -912,11 +912,16 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '세션 {count}개를 보관하지 못했습니다', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '세션 {count}개를 삭제하지 못했습니다', 'settings.openchamber.desktopNetwork.title': 'Desktop 네트워크 접속', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '창 컨트롤 위치', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '최소화, 최대화, 닫기 버튼이 표시될 위치를 선택합니다. 기본값은 오른쪽입니다.', + 'settings.openchamber.desktopNetwork.field.windowControls': '창 컨트롤', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '창 컨트롤의 위치와 모양을 선택합니다.', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '위치', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '창 컨트롤 위치', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '스타일', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '창 컨트롤 스타일', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '왼쪽', 'settings.openchamber.desktopNetwork.option.windowControlsRight': '오른쪽', + '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': '창을 열지 않고 백그라운드에서 앱을 시작합니다. 데스크톱 상태 아이콘으로 열 수 있습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index aeb894da..8df7ea56 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -780,11 +780,16 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.title': 'Dostęp sieciowy pulpitu', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Pozycja elementów sterujących oknem', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Wybierz, gdzie mają się pojawiać przyciski minimalizacji, maksymalizacji i zamykania. Domyślnie po prawej.', + 'settings.openchamber.desktopNetwork.field.windowControls': 'Elementy sterujące oknem', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Wybierz, gdzie mają się pojawiać elementy sterujące oknem i jak mają wyglądać.', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Pozycja', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Pozycja elementów sterujących oknem', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Styl', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Styl elementów sterujących oknem', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': 'Lewo', 'settings.openchamber.desktopNetwork.option.windowControlsRight': 'Prawo', + '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 45fb008d..da8882ef 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -912,11 +912,16 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Não foi possível arquivar {count} sessão(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Não foi possível excluir {count} sessão(es)", "settings.openchamber.desktopNetwork.title": "Acesso de rede do desktop", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posição dos controles da janela", - "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Escolha onde os botões de minimizar, maximizar e fechar aparecem. O padrão é à direita.", + "settings.openchamber.desktopNetwork.field.windowControls": "Controles da janela", + "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Escolha onde os controles da janela aparecem e como eles são exibidos.", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posição", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posição dos controles da janela", + "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo", + "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo dos controles da janela", "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Esquerda", "settings.openchamber.desktopNetwork.option.windowControlsRight": "Direita", + "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.", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index e5e97146..2c38eb96 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -912,11 +912,16 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Не вдалося заархівувати сесій: {count}", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Не вдалося видалити сесій: {count}", "settings.openchamber.desktopNetwork.title": "Мережевий доступ десктопного застосунку", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Позиція елементів керування вікном", - "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Виберіть, де з’являються кнопки згортання, розгортання та закриття. За замовчуванням справа.", + "settings.openchamber.desktopNetwork.field.windowControls": "Елементи керування вікном", + "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Виберіть, де з’являються елементи керування вікном і як вони виглядають.", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Позиція", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Позиція елементів керування вікном", + "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Стиль", + "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Стиль елементів керування вікном", "settings.openchamber.desktopNetwork.option.windowControlsLeft": "Зліва", "settings.openchamber.desktopNetwork.option.windowControlsRight": "Справа", + "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": "Запускає застосунок у фоні без відкриття вікна. Відкрийте його через піктограму стану на робочому столі.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 9ece1ed5..020dad21 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -912,11 +912,16 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '归档 {count} 个会话失败', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '删除 {count} 个会话失败', 'settings.openchamber.desktopNetwork.title': '桌面端网络访问', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '窗口控件位置', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '选择最小化、最大化和关闭按钮的显示位置。默认在右侧。', + 'settings.openchamber.desktopNetwork.field.windowControls': '窗口控件', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '选择窗口控件的位置和外观。', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '窗口控件位置', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '样式', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '窗口控件样式', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左侧', 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右侧', + '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': '在后台启动应用且不打开窗口。可通过桌面状态图标打开。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 9d88b44e..3825b37a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -909,11 +909,16 @@ 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '封存 {count} 個工作階段失敗', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '刪除 {count} 個工作階段失敗', 'settings.openchamber.desktopNetwork.title': '桌面端網路存取', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '視窗控制項位置', - 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '選擇最小化、最大化和關閉按鈕的顯示位置。預設在右側。', + 'settings.openchamber.desktopNetwork.field.windowControls': '視窗控制項', + 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '選擇視窗控制項的位置和外觀。', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '視窗控制項位置', + 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '樣式', + 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '視窗控制項樣式', 'settings.openchamber.desktopNetwork.option.windowControlsLeft': '左側', 'settings.openchamber.desktopNetwork.option.windowControlsRight': '右側', + '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 下的其他電腦存取。', diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 7db5b302..f8395f16 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -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; diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 34a5f415..bed325dc 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -399,9 +399,16 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ { id: 'sessions.desktop-window-controls-position', page: 'appearance', - titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', - descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', - keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'], + titleKey: 'settings.openchamber.desktopNetwork.field.windowControls', + descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsDescription', + keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows', 'position'], + 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), }, { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 1e35a5f9..4c1185d0 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -13,6 +13,7 @@ import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence' import { startTypographyWatcher } from './lib/typographyWatcher' import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave' import { initializeLocale, I18nProvider } from './lib/i18n' +import { initLinuxWindowBorder } from './lib/desktop' import type { RuntimeAPIs } from './lib/api/types' declare global { @@ -26,6 +27,7 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI })(); initializeLocale(); +initLinuxWindowBorder(); // Initialize settings asynchronously — the app renders with defaults first // and hydrates once persisted preferences are applied. Users with non-default diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 817ec27f..eb480444 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( timeFormatPreference: 'auto', weekStartPreference: 'auto', desktopWindowControlsPosition: 'right', + desktopWindowControlsStyle: 'classic', mermaidRenderingMode: 'svg', userMessageRenderingMode: 'markdown', collapsibleUserMessages: true, @@ -2169,6 +2173,9 @@ export const useUIStore = create()( setDesktopWindowControlsPosition: (value) => { set({ desktopWindowControlsPosition: value === 'left' ? 'left' : 'right' }); }, + setDesktopWindowControlsStyle: (value) => { + set({ desktopWindowControlsStyle: value === 'traffic-lights' ? 'traffic-lights' : 'classic' }); + }, setMermaidRenderingMode: (value) => { set({ mermaidRenderingMode: value }); }, @@ -2247,7 +2254,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 12, + version: 13, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2261,6 +2268,15 @@ export const useUIStore = create()( } } + // v12 -> v13: introduce window-controls style. Existing users keep + // their persisted position; style defaults to "classic" so no one + // suddenly sees traffic lights on upgrade. + if (version < 13) { + if (state.desktopWindowControlsStyle !== 'classic' && state.desktopWindowControlsStyle !== 'traffic-lights') { + state.desktopWindowControlsStyle = 'classic'; + } + } + // v10 -> v11: move the previous terminal font default forward. if (version < 11 && state.terminalFontSize === 13) { state.terminalFontSize = 14; @@ -2439,6 +2455,7 @@ export const useUIStore = create()( timeFormatPreference: state.timeFormatPreference, weekStartPreference: state.weekStartPreference, desktopWindowControlsPosition: state.desktopWindowControlsPosition, + desktopWindowControlsStyle: state.desktopWindowControlsStyle, mermaidRenderingMode: state.mermaidRenderingMode, userMessageRenderingMode: state.userMessageRenderingMode, collapsibleUserMessages: state.collapsibleUserMessages, diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 04a918e6..4c799991 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -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; diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 7fdacbc9..3bc30c41 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -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(); From 9001e38c545e5a280a59fd327b1a6540d5ea251e Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Wed, 29 Jul 2026 07:35:14 +0200 Subject: [PATCH 2/5] feat(desktop): gate traffic-lights behind window-controls style setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Window controls Style setting ('classic' | 'traffic-lights', default 'classic') next to the existing Position setting. All four position×style combinations render correctly with side-driven order (close,min,max left; min,max,close right). - Restore deleted left-classic branch (h-8 w-8 rounded-md buttons) - Add right-traffic-lights branch (cluster with ml-1 container) - Branch on style instead of side in WindowsWindowControls - Store field + setter with 'classic' default (no migration needed; Zustand persist shallow-merges over initial state) - Settings UI: SettingsTwoColumn with Position chips + Style dropdown - Sanitize on client (persistence.ts) and server (settings-helpers.js) - 10 locale dictionaries updated (add-only, no existing keys changed) - Traffic-light button spacing 8px→10px edge-to-edge - Delete dead DESKTOP_WINDOW_CONTROLS_WIDTH_PX constant --- .../desktop/WindowsWindowControls.tsx | 32 +++++++++---------- packages/ui/src/components/icon/sprite.ts | 2 -- .../openchamber/OpenChamberVisualSettings.tsx | 25 ++++++--------- packages/ui/src/index.css | 11 ------- packages/ui/src/lib/desktop.ts | 31 ------------------ .../ui/src/lib/desktopWindowControls.test.ts | 22 ------------- .../ui/src/lib/i18n/messages/en.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/es.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/fr.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/ja.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/ko.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/pl.settings.ts | 10 +++--- .../src/lib/i18n/messages/pt-BR.settings.ts | 10 +++--- .../ui/src/lib/i18n/messages/uk.settings.ts | 10 +++--- .../src/lib/i18n/messages/zh-CN.settings.ts | 10 +++--- .../src/lib/i18n/messages/zh-TW.settings.ts | 10 +++--- packages/ui/src/lib/settings/search.ts | 6 ++-- packages/ui/src/main.tsx | 2 -- packages/ui/src/stores/useUIStore.ts | 11 +------ 19 files changed, 80 insertions(+), 162 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 7c5e8bcd..9db608d8 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -16,24 +16,24 @@ const TRAFFIC_LIGHT_COLORS: Record = ({ action }) => { +const TrafficLightGlyph: React.FC<{ action: DesktopWindowControlAction; isMaximized: boolean }> = ({ action, isMaximized }) => { if (action === 'close') { - return ( - - - - ); + return ; } if (action === 'minimize') { - return ( - - - - ); + return ; } - // Green expand: two paired corner triangles pointing diagonally outward. - return ( - + // Green expand: two filled corner triangles pointing outward (maximize) + // or inward (restore). No Remixicon equivalent exists. + // Glyphs are 10px inside the 14px circle on purpose: 9px left a half-pixel + // (2.5px) padding that rasterized lopsided at 1x DPR. + return isMaximized ? ( + + + + + ) : ( + @@ -72,8 +72,8 @@ const TrafficLightButton: React.FC = ({ action, isMaxim 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: glyph }} > - - + + diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 386357b9..c9360d1a 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -18,7 +18,6 @@ export const iconSpriteData = { "arrow-go-back": ``, "arrow-go-forward": ``, "arrow-left": ``, - "arrow-left-long": ``, "arrow-left-right": ``, "arrow-left-s": ``, "arrow-right": ``, @@ -211,7 +210,6 @@ export const iconSpriteData = { "shuffle": ``, "slash-commands-2": ``, "smartphone": ``, - "sort-desc": ``, "sparkling": ``, "split-cells-horizontal": ``, "stack": ``, diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index bd2b395e..a9f6c571 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -751,10 +751,6 @@ export const OpenChamberVisualSettings: React.FC const option = MOBILE_KEYBOARD_MODE_OPTIONS.find((item) => item.id === mobileKeyboardMode); return option ? tUnsafe(option.labelKey) : undefined; }, [mobileKeyboardMode, tUnsafe]); - const selectedWindowControlsStyleLabel = React.useMemo(() => { - const option = WINDOW_CONTROLS_STYLE_OPTIONS.find((item) => item.id === desktopWindowControlsStyle); - return option ? tUnsafe(option.labelKey) : undefined; - }, [desktopWindowControlsStyle, tUnsafe]); const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => { if (value === mobileLayoutPreference) { @@ -1028,7 +1024,7 @@ export const OpenChamberVisualSettings: React.FC {showWindowControlsPositionSetting && ( @@ -1050,16 +1046,15 @@ export const OpenChamberVisualSettings: React.FC label={t('settings.openchamber.desktopNetwork.field.windowControlsStyle')} settingsItem="sessions.desktop-window-controls-style" > - + ({ + value: option.id, + label: tUnsafe(option.labelKey), + }))} + onChange={handleWindowControlsStyleChange} + aria-label={t('settings.openchamber.desktopNetwork.field.windowControlsStyleAria')} + /> diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 11a59191..347533fc 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -71,17 +71,6 @@ html[data-oc-vibrancy] .main-content-safe-area { background-color: transparent !important; } -/* ── Linux frameless window border ──────────────────────────────────────── - `data-oc-window-border` is set on by initLinuxWindowBorder() - (ui/src/lib/desktop.ts), and `data-oc-window-maximized` drops the border - so a maximized window stays flush with screen edges. Rounded corners - were removed: Electron 41's transparent-window path on Linux is unreliable - without --disable-gpu, and native rounded corners landed upstream only in - Electron 43 (PR #51459). */ -html[data-oc-window-border]:not([data-oc-window-maximized]) #root { - border: 1px solid var(--border); -} - /* Left sidebar: solid until the native layer is ready, then a translucent overlay so the vibrancy frosts through. */ html[data-oc-vibrancy] .oc-vibrancy-surface { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 10194028..b09f2b60 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -300,37 +300,6 @@ export const hasDesktopInvoke = (): boolean => { return typeof getDesktopBridge()?.invoke === 'function'; }; -/** - * Linux frameless Electron windows show a 1px CSS border on #root (see - * index.css). This function flips the attribute on so the rule - * matches, and tracks `data-oc-window-maximized` so the border drops when - * maximized (a 1px line inset from every screen edge would look off). - * No-op outside Linux frameless Electron. - */ -export const initLinuxWindowBorder = (): void => { - if (typeof window === 'undefined' || typeof document === 'undefined') return; - if (!usesFramelessElectronChrome()) return; - const platform = getElectronPlatform(); - if (platform !== 'linux') return; - - const root = document.documentElement; - root.setAttribute('data-oc-window-border', ''); - - const applyMaximized = (maximized: boolean) => { - root.toggleAttribute('data-oc-window-maximized', maximized); - }; - - void invokeDesktop<{ maximized?: boolean }>('desktop_get_current_window_state') - .then((state) => applyMaximized(Boolean(state?.maximized))) - .catch(() => {}); - - const onMaximizedChange = (event: Event) => { - const detail = (event as CustomEvent<{ maximized?: boolean }>).detail; - applyMaximized(Boolean(detail?.maximized)); - }; - window.addEventListener('openchamber:window-maximized-changed', onMaximizedChange); -}; - export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke(); export const invokeDesktop = async (command: string, args?: Record): Promise => { diff --git a/packages/ui/src/lib/desktopWindowControls.test.ts b/packages/ui/src/lib/desktopWindowControls.test.ts index 2a8237a9..a38e0159 100644 --- a/packages/ui/src/lib/desktopWindowControls.test.ts +++ b/packages/ui/src/lib/desktopWindowControls.test.ts @@ -6,7 +6,6 @@ import { normalizeDesktopWindowControlsPosition, resolveDesktopWindowControlsSide, } from './desktop'; -import { useUIStore } from '@/stores/useUIStore'; describe('desktop window controls position', () => { test('defaults to right', () => { @@ -31,24 +30,3 @@ describe('desktop window controls position', () => { expect(getDesktopWindowControlsOrder('right')).toEqual(['minimize', 'maximize', 'close']); }); }); - -describe('useUIStore v12→v13 migration', () => { - test('introduces classic style while preserving position', () => { - const migrate = useUIStore.persist.getOptions().migrate!; - const result = migrate({ desktopWindowControlsPosition: 'left' }, 12) as Record; - expect(result.desktopWindowControlsPosition).toBe('left'); - expect(result.desktopWindowControlsStyle).toBe('classic'); - }); - - test('does not overwrite a persisted style', () => { - const migrate = useUIStore.persist.getOptions().migrate!; - const result = migrate({ desktopWindowControlsStyle: 'traffic-lights' }, 12) as Record; - expect(result.desktopWindowControlsStyle).toBe('traffic-lights'); - }); - - test('coerces an invalid persisted style to classic', () => { - const migrate = useUIStore.persist.getOptions().migrate!; - const result = migrate({ desktopWindowControlsStyle: 'macos' }, 12) as Record; - expect(result.desktopWindowControlsStyle).toBe('classic'); - }); -}); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 99c80a86..07d2bbb1 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -945,14 +945,14 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Failed to archive {count} session(s)', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Failed to delete {count} session(s)', 'settings.openchamber.desktopNetwork.title': 'Desktop Network Access', - 'settings.openchamber.desktopNetwork.field.windowControls': 'Window controls', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Choose where window controls appear and how they look.', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Window controls position', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choose where minimize, maximize, and close buttons appear. Defaults to the right.', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Window controls position', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Style', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Window controls style', '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', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 8b3505b7..5997ed0a 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -912,14 +912,14 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "No se pudo archivar {count} sesión(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "No se pudo eliminar {count} sesión(es)", "settings.openchamber.desktopNetwork.title": "Acceso de red de escritorio", - "settings.openchamber.desktopNetwork.field.windowControls": "Controles de ventana", - "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Elige dónde aparecen los controles de ventana y su apariencia.", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posición", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posición de los controles de ventana", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Elige dónde aparecen los botones de minimizar, maximizar y cerrar. Por defecto a la derecha.", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posición de los controles de ventana", - "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo", - "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo 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", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index dcd67aa8..a081feba 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -833,14 +833,14 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': 'Échec de l\'archivage des sessions {count}', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': 'Échec de la suppression des sessions {count}', 'settings.openchamber.desktopNetwork.title': 'Accès au réseau de bureau', - 'settings.openchamber.desktopNetwork.field.windowControls': 'Contrôles de fenêtre', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Choisissez où apparaissent les contrôles de fenêtre et leur apparence.', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Position des contrôles de fenêtre', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Choisissez où apparaissent les boutons Réduire, Agrandir et Fermer. Par défaut à droite.', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Position des 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.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', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 5043efa3..133071e8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -945,14 +945,14 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '{count} 個の Session のアーカイブに失敗しました', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '{count} 個の Session の削除に失敗しました', 'settings.openchamber.desktopNetwork.title': 'Desktop ネットワークアクセス', - 'settings.openchamber.desktopNetwork.field.windowControls': 'ウィンドウコントロール', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'ウィンドウコントロールの表示位置とスタイルを選びます。', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'ウィンドウコントロールの位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '最小化・最大化・閉じるボタンの表示位置を選びます。デフォルトは右側です。', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'ウィンドウコントロールの位置', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'スタイル', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'ウィンドウコントロールのスタイル', '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 を起動', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 54841d45..3b673361 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -912,14 +912,14 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '세션 {count}개를 보관하지 못했습니다', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '세션 {count}개를 삭제하지 못했습니다', 'settings.openchamber.desktopNetwork.title': 'Desktop 네트워크 접속', - 'settings.openchamber.desktopNetwork.field.windowControls': '창 컨트롤', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '창 컨트롤의 위치와 모양을 선택합니다.', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '위치', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '창 컨트롤 위치', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '최소화, 최대화, 닫기 버튼이 표시될 위치를 선택합니다. 기본값은 오른쪽입니다.', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '창 컨트롤 위치', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '스타일', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '창 컨트롤 스타일', '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 시작', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 8df7ea56..516e2b59 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -780,14 +780,14 @@ export const settingsDict = { 'settings.openchamber.desktopNetwork.hint.openAfterRestart': 'Po restarcie otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.hint.openNow': 'Otwórz z innego urządzenia: ', 'settings.openchamber.desktopNetwork.title': 'Dostęp sieciowy pulpitu', - 'settings.openchamber.desktopNetwork.field.windowControls': 'Elementy sterujące oknem', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': 'Wybierz, gdzie mają się pojawiać elementy sterujące oknem i jak mają wyglądać.', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Pozycja', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': 'Pozycja elementów sterujących oknem', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': 'Wybierz, gdzie mają się pojawiać przyciski minimalizacji, maksymalizacji i zamykania. Domyślnie po prawej.', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': 'Pozycja elementów sterujących oknem', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': 'Styl', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': 'Styl 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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index da8882ef..b671e38c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -912,14 +912,14 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Não foi possível arquivar {count} sessão(es)", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Não foi possível excluir {count} sessão(es)", "settings.openchamber.desktopNetwork.title": "Acesso de rede do desktop", - "settings.openchamber.desktopNetwork.field.windowControls": "Controles da janela", - "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Escolha onde os controles da janela aparecem e como eles são exibidos.", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posição", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Posição dos controles da janela", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Escolha onde os botões de minimizar, maximizar e fechar aparecem. O padrão é à direita.", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Posição dos controles da janela", - "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Estilo", - "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Estilo 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", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 2c38eb96..67106832 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -912,14 +912,14 @@ export const settingsDict = { "settings.openchamber.sessionRetention.toast.failedArchiveCount": "Не вдалося заархівувати сесій: {count}", "settings.openchamber.sessionRetention.toast.failedDeleteCount": "Не вдалося видалити сесій: {count}", "settings.openchamber.desktopNetwork.title": "Мережевий доступ десктопного застосунку", - "settings.openchamber.desktopNetwork.field.windowControls": "Елементи керування вікном", - "settings.openchamber.desktopNetwork.field.windowControlsDescription": "Виберіть, де з’являються елементи керування вікном і як вони виглядають.", - "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Позиція", + "settings.openchamber.desktopNetwork.field.windowControlsPosition": "Позиція елементів керування вікном", + "settings.openchamber.desktopNetwork.field.windowControlsPositionDescription": "Виберіть, де з’являються кнопки згортання, розгортання та закриття. За замовчуванням справа.", "settings.openchamber.desktopNetwork.field.windowControlsPositionAria": "Позиція елементів керування вікном", - "settings.openchamber.desktopNetwork.field.windowControlsStyle": "Стиль", - "settings.openchamber.desktopNetwork.field.windowControlsStyleAria": "Стиль елементів керування вікном", "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 під час входу в систему", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 020dad21..c6f60506 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -912,14 +912,14 @@ export const settingsDict = { 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '归档 {count} 个会话失败', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '删除 {count} 个会话失败', 'settings.openchamber.desktopNetwork.title': '桌面端网络访问', - 'settings.openchamber.desktopNetwork.field.windowControls': '窗口控件', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '选择窗口控件的位置和外观。', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '窗口控件位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '选择最小化、最大化和关闭按钮的显示位置。默认在右侧。', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '窗口控件位置', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '样式', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '窗口控件样式', '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', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 3825b37a..b4f7f5fb 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -909,14 +909,14 @@ 'settings.openchamber.sessionRetention.toast.failedArchiveCount': '封存 {count} 個工作階段失敗', 'settings.openchamber.sessionRetention.toast.failedDeleteCount': '刪除 {count} 個工作階段失敗', 'settings.openchamber.desktopNetwork.title': '桌面端網路存取', - 'settings.openchamber.desktopNetwork.field.windowControls': '視窗控制項', - 'settings.openchamber.desktopNetwork.field.windowControlsDescription': '選擇視窗控制項的位置和外觀。', - 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPosition': '視窗控制項位置', + 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription': '選擇最小化、最大化和關閉按鈕的顯示位置。預設在右側。', 'settings.openchamber.desktopNetwork.field.windowControlsPositionAria': '視窗控制項位置', - 'settings.openchamber.desktopNetwork.field.windowControlsStyle': '樣式', - 'settings.openchamber.desktopNetwork.field.windowControlsStyleAria': '視窗控制項樣式', '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 區域網路存取', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index bed325dc..c95d1b54 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -399,9 +399,9 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ { id: 'sessions.desktop-window-controls-position', page: 'appearance', - titleKey: 'settings.openchamber.desktopNetwork.field.windowControls', - descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsDescription', - keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows', 'position'], + titleKey: 'settings.openchamber.desktopNetwork.field.windowControlsPosition', + descriptionKey: 'settings.openchamber.desktopNetwork.field.windowControlsPositionDescription', + keywords: ['desktop', 'window', 'controls', 'minimize', 'maximize', 'close', 'titlebar', 'linux', 'windows'], isAvailable: (ctx) => ctx.isDesktop && (ctx.isWindows || !ctx.isMac), }, { diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 4c1185d0..1e35a5f9 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -13,7 +13,6 @@ import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence' import { startTypographyWatcher } from './lib/typographyWatcher' import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave' import { initializeLocale, I18nProvider } from './lib/i18n' -import { initLinuxWindowBorder } from './lib/desktop' import type { RuntimeAPIs } from './lib/api/types' declare global { @@ -27,7 +26,6 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI })(); initializeLocale(); -initLinuxWindowBorder(); // Initialize settings asynchronously — the app renders with defaults first // and hydrates once persisted preferences are applied. Users with non-default diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index eb480444..88f08930 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -2254,7 +2254,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 13, + version: 12, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2268,15 +2268,6 @@ export const useUIStore = create()( } } - // v12 -> v13: introduce window-controls style. Existing users keep - // their persisted position; style defaults to "classic" so no one - // suddenly sees traffic lights on upgrade. - if (version < 13) { - if (state.desktopWindowControlsStyle !== 'classic' && state.desktopWindowControlsStyle !== 'traffic-lights') { - state.desktopWindowControlsStyle = 'classic'; - } - } - // v10 -> v11: move the previous terminal font default forward. if (version < 11 && state.terminalFontSize === 13) { state.terminalFontSize = 14; From c54bc63a741c66dd931ee34515b12c4fb9e32374 Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Wed, 29 Jul 2026 12:51:17 +0200 Subject: [PATCH 3/5] feat(desktop): use add-line glyph for maximize/restore traffic light Replaces the custom corner-triangle SVG with Remixicon's `add` (+) glyph for both maximize and restore states in traffic-lights style, matching the close/minimize glyphs. The restore vs maximize label still flips via isMaximized. --- .../desktop/WindowsWindowControls.tsx | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index 9db608d8..e722fc11 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -16,28 +16,16 @@ const TRAFFIC_LIGHT_COLORS: Record = ({ action, isMaximized }) => { +const TrafficLightGlyph: React.FC<{ action: DesktopWindowControlAction }> = ({ action }) => { if (action === 'close') { return ; } if (action === 'minimize') { return ; } - // Green expand: two filled corner triangles pointing outward (maximize) - // or inward (restore). No Remixicon equivalent exists. - // Glyphs are 10px inside the 14px circle on purpose: 9px left a half-pixel - // (2.5px) padding that rasterized lopsided at 1x DPR. - return isMaximized ? ( - - - - - ) : ( - - - - - ); + // Green maximize/restore: same `add` (+) glyph for both states. The label + // (restore vs maximize) still flips via isMaximized in TrafficLightButton. + return ; }; type TrafficLightButtonProps = { @@ -73,7 +61,7 @@ const TrafficLightButton: React.FC = ({ action, isMaxim style={{ backgroundColor: fill, color: glyph }} > - + From 8c7fec23566cb4a4235db5884c83f8819de40cb9 Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Wed, 29 Jul 2026 13:49:12 +0200 Subject: [PATCH 4/5] chore(desktop): narrow PR to feature scope Revert out-of-scope sprite.ts regeneration from d971696f that dropped two unrelated dead icons (arrow-left-long, sort-desc); those belong in a separate dead-code cleanup if desired. Add a comment at the DesktopWindowControlsStyle type noting control width is style-dependent (classic vs traffic-lights), so no fixed-width constant applies. --- packages/ui/src/components/icon/sprite.ts | 2 ++ packages/ui/src/lib/desktop.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index c9360d1a..386357b9 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -18,6 +18,7 @@ export const iconSpriteData = { "arrow-go-back": ``, "arrow-go-forward": ``, "arrow-left": ``, + "arrow-left-long": ``, "arrow-left-right": ``, "arrow-left-s": ``, "arrow-right": ``, @@ -210,6 +211,7 @@ export const iconSpriteData = { "shuffle": ``, "slash-commands-2": ``, "smartphone": ``, + "sort-desc": ``, "sparkling": ``, "split-cells-horizontal": ``, "stack": ``, diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index b09f2b60..bd5e9d12 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -41,6 +41,7 @@ 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 = { From 1f4eedf793ff69a140fa257bcf4e77652367ec0d Mon Sep 17 00:00:00 2001 From: Pablo Gonzalez Date: Wed, 29 Jul 2026 14:46:48 +0200 Subject: [PATCH 5/5] fix(desktop): uniform translucent-black traffic-light glyphs Use a single rgba(0,0,0,0.7) glyph color across all three traffic-light buttons instead of per-action hue-tinted darks. Black-with-opacity lets the fill tint the symbol, matching macOS chrome and reading cleanly on all three fills. Drops the redundant per-action glyph field. --- .../components/desktop/WindowsWindowControls.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/desktop/WindowsWindowControls.tsx b/packages/ui/src/components/desktop/WindowsWindowControls.tsx index e722fc11..8c8e88c5 100644 --- a/packages/ui/src/components/desktop/WindowsWindowControls.tsx +++ b/packages/ui/src/components/desktop/WindowsWindowControls.tsx @@ -10,11 +10,14 @@ 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_COLORS: Record = { - close: { fill: '#FF5F57', glyph: '#7F0808' }, - minimize: { fill: '#FEBC2E', glyph: '#B3710C' }, - maximize: { fill: '#28C940', glyph: '#006200' }, +const TRAFFIC_LIGHT_FILL: Record = { + 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') { @@ -36,7 +39,7 @@ type TrafficLightButtonProps = { const TrafficLightButton: React.FC = ({ action, isMaximized, onActivate }) => { const { t } = useI18n(); - const { fill, glyph } = TRAFFIC_LIGHT_COLORS[action]; + const fill = TRAFFIC_LIGHT_FILL[action]; const label = action === 'close' ? t('header.windowControls.close') @@ -58,7 +61,7 @@ const TrafficLightButton: React.FC = ({ action, isMaxim >