From c2f11ab37eaa745d859b3b7d4c7ab3b5c0749ca9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 27 Mar 2026 20:38:52 -0500 Subject: [PATCH] Add opacity controls for window and sidebar - Persist opacity settings in app state - Bridge window opacity to Electron and style the sidebar via CSS - Add sliders and reset actions in settings --- apps/desktop/src/main.ts | 19 +++++++ apps/desktop/src/preload.ts | 4 ++ apps/web/src/appSettings.ts | 8 +++ apps/web/src/routes/_chat.settings.tsx | 74 ++++++++++++++++++++++++++ apps/web/src/routes/_chat.tsx | 9 ++++ packages/contracts/src/ipc.ts | 2 + 6 files changed, 116 insertions(+) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 14f42f1cd..06a503c37 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -52,6 +52,8 @@ syncShellEnvironment(); const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const SET_WINDOW_OPACITY_CHANNEL = "desktop:set-window-opacity"; +const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -1151,6 +1153,23 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeHandler(SET_WINDOW_OPACITY_CHANNEL); + ipcMain.handle(SET_WINDOW_OPACITY_CHANNEL, async (event, rawOpacity: unknown) => { + if (typeof rawOpacity !== "number" || !Number.isFinite(rawOpacity)) return; + const opacity = Math.max(0.3, Math.min(1, rawOpacity)); + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + window.setOpacity(opacity); + } + }); + + ipcMain.removeHandler(SET_SIDEBAR_OPACITY_CHANNEL); + ipcMain.handle(SET_SIDEBAR_OPACITY_CHANNEL, async (_event, rawOpacity: unknown) => { + // Sidebar opacity is handled purely on the renderer side via CSS. + // This channel exists so the bridge contract is satisfied; the renderer + // applies the value through a CSS custom-property. + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 602c5a65c..d90835173 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,8 @@ import type { DesktopBridge } from "@okcode/contracts"; const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const SET_THEME_CHANNEL = "desktop:set-theme"; +const SET_WINDOW_OPACITY_CHANNEL = "desktop:set-window-opacity"; +const SET_SIDEBAR_OPACITY_CHANNEL = "desktop:set-sidebar-opacity"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; @@ -25,6 +27,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + setWindowOpacity: (opacity) => ipcRenderer.invoke(SET_WINDOW_OPACITY_CHANNEL, opacity), + setSidebarOpacity: (opacity) => ipcRenderer.invoke(SET_SIDEBAR_OPACITY_CHANNEL, opacity), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index b191ea71c..ca20ceb7c 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -72,6 +72,8 @@ export const AppSettingsSchema = Schema.Struct({ withDefaults(() => DEFAULT_SIDEBAR_THREAD_SORT_ORDER), ), timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), + windowOpacity: Schema.Number.pipe(withDefaults(() => 1)), + sidebarOpacity: Schema.Number.pipe(withDefaults(() => 1)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), @@ -136,9 +138,15 @@ export function normalizeCustomModelSlugs( return normalizedModels; } +function clampOpacity(value: number): number { + return Math.max(0.3, Math.min(1, value)); +} + function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, + windowOpacity: clampOpacity(settings.windowOpacity), + sidebarOpacity: clampOpacity(settings.sidebarOpacity), customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customClaudeModels: normalizeCustomModelSlugs(settings.customClaudeModels, "claudeAgent"), }; diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 73fcc34dd..16ddc4cb8 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -499,6 +499,80 @@ function SettingsRouteView() { } /> + { + updateSettings({ windowOpacity: defaults.windowOpacity }); + if (isElectron && window.desktopBridge) { + void window.desktopBridge.setWindowOpacity(defaults.windowOpacity); + } + }} + /> + ) : null + } + control={ +
+ { + const value = Number(e.target.value) / 100; + updateSettings({ windowOpacity: value }); + if (isElectron && window.desktopBridge) { + void window.desktopBridge.setWindowOpacity(value); + } + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Window opacity" + /> + + {Math.round(settings.windowOpacity * 100)}% + +
+ } + /> + + + updateSettings({ sidebarOpacity: defaults.sidebarOpacity }) + } + /> + ) : null + } + control={ +
+ { + const value = Number(e.target.value) / 100; + updateSettings({ sidebarOpacity: value }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar opacity" + /> + + {Math.round(settings.sidebarOpacity * 100)}% + +
+ } + /> + { const onMenuAction = window.desktopBridge?.onMenuAction; @@ -112,6 +113,13 @@ function ChatRouteLayout() { }; }, [navigate]); + // Apply window opacity via the desktop bridge when the setting changes + useEffect(() => { + if (window.desktopBridge) { + void window.desktopBridge.setWindowOpacity(settings.windowOpacity); + } + }, [settings.windowOpacity]); + return ( @@ -119,6 +127,7 @@ function ChatRouteLayout() { side="left" collapsible="offcanvas" className="border-r border-border bg-card text-foreground" + style={{ opacity: settings.sidebarOpacity }} resizable={{ minWidth: THREAD_SIDEBAR_MIN_WIDTH, shouldAcceptWidth: ({ nextWidth, wrapper }) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 35d8deb1c..32d299890 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -143,6 +143,8 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + setWindowOpacity: (opacity: number) => Promise; + setSidebarOpacity: (opacity: number) => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number },