Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions src/main/ipc/localHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ import {
defineMainLocalIpcHandlers,
type MainLocalIpcHandlerMap,
type WindowChromePayload,
type WindowChromeResult,
} from "@/shared/ipc";
import { supportsNativeWindowMaterial, syncNativeThemeForMaterial } from "../window/windowMaterial";
import type { AgentInstanceConfig } from "@/shared/contracts";
import type { LightcodePaths } from "@/shared/lightcodePaths";
import { UsageLoginManager } from "../usageLogin/UsageLoginManager";
Expand All @@ -52,6 +54,8 @@ interface CreateLocalIpcHandlersOptions {
updatePowerSaveBlocker(): void;
autoUpdater: AutoUpdaterController;
onSharedSettingsChanged?(): void;
/** Relaunch the app (exposed via the relaunchApp IPC). */
requestRelaunch(): void;
}

function requireBrowserPanel(getter: () => BrowserPanelManager | null): BrowserPanelManager {
Expand Down Expand Up @@ -132,6 +136,9 @@ export function createLocalIpcHandlers(
}
win.focus();
},
relaunchApp: () => {
options.requestRelaunch();
},
getHomeScopeLocation: () =>
process.platform === "win32"
? { kind: "windows", path: homedir() }
Expand Down Expand Up @@ -191,10 +198,11 @@ export function createLocalIpcHandlers(
options.onSharedSettingsChanged?.();
return instance;
},
setWindowChrome: async (payload: WindowChromePayload) => {
setWindowChrome: async (payload: WindowChromePayload): Promise<WindowChromeResult> => {
const nativeCapable = supportsNativeWindowMaterial();
const mainWindow = options.getMainWindow();
if (!mainWindow) {
return;
return { nativeCapable };
}
if (process.platform === "win32" || process.platform === "linux") {
mainWindow.setTitleBarOverlay({
Expand All @@ -203,6 +211,20 @@ export function createLocalIpcHandlers(
height: 32,
});
}
// Toggle the native translucency material live. macOS vibrancy is created
// with the window and revealed/hidden purely via CSS, so there is nothing
// to switch here. Windows acrylic is toggled at runtime (no relaunch).
const wantsMaterial = payload.materialEnabled === true && nativeCapable;
if (process.platform === "win32") {
mainWindow.setBackgroundMaterial(wantsMaterial ? "acrylic" : "none");
mainWindow.setBackgroundColor(
wantsMaterial ? "#00000000" : payload.appearance === "dark" ? "#141416" : "#f1f1f4",
);
}
if (wantsMaterial && payload.appearance) {
syncNativeThemeForMaterial(payload.appearance);
}
return { nativeCapable };
},
dbGetProjects: () => dbGetProjects(),
dbGetThreads: () => dbGetThreads(),
Expand Down
35 changes: 27 additions & 8 deletions src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,19 +93,29 @@ function isCloseToTrayEnabled(): boolean {
}

/**
* Resolves the saved appearance so the native window opens with a matching
* background instead of flashing a fixed color before the renderer paints.
* Resolves the saved appearance + opt-in translucent ("liquid glass") sidebar in
* a single settings read, so the window opens already matching the theme and
* material (flash-free first paint) before the renderer paints.
*/
function resolveAppAppearance(): "light" | "dark" {
function resolveWindowChromeOptions(): {
appearance: "light" | "dark";
sidebarTranslucency: boolean;
} {
let mode: "system" | "light" | "dark" = "dark";
let wantGlass = false;
if (lightcodePaths) {
try {
mode = readSharedSettingsFile(lightcodePaths.settingsPath).themeMode;
const settings = readSharedSettingsFile(lightcodePaths.settingsPath);
mode = settings.themeMode;
wantGlass = settings.sidebarTranslucency === true;
} catch {
// Fall back to dark.
// Fall back to dark / opaque.
}
}
return resolveThemeMode(mode, nativeTheme.shouldUseDarkColors);
return {
appearance: resolveThemeMode(mode, nativeTheme.shouldUseDarkColors),
sidebarTranslucency: wantGlass,
};
}

function primeBrowserAllowFlags(): void {
Expand Down Expand Up @@ -278,10 +288,16 @@ if (!hasSingleInstanceLock) {
updatePowerSaveBlocker,
autoUpdater: autoUpdaterController,
onSharedSettingsChanged: primeBrowserAllowFlags,
requestRelaunch: () => {
isQuitting = true;
app.relaunch();
app.quit();
},
}),
callSupervisor: (name, payload) => supervisorClient.call(name, payload),
});

const windowChrome = resolveWindowChromeOptions();
mainWindow = createMainWindow({
title: getAppName(channel, isDev),
isDev,
Expand All @@ -296,7 +312,8 @@ if (!hasSingleInstanceLock) {
sentryEnabled,
windowChromeHeight: WINDOW_CHROME_HEIGHT,
browserUserAgent: chromeLikeUserAgent,
appearance: resolveAppAppearance(),
appearance: windowChrome.appearance,
sidebarTranslucency: windowChrome.sidebarTranslucency,
...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}),
onClosed: () => {
mainWindow = null;
Expand Down Expand Up @@ -371,6 +388,7 @@ if (!hasSingleInstanceLock) {
return;
}
if (BrowserWindow.getAllWindows().length === 0) {
const reopenChrome = resolveWindowChromeOptions();
mainWindow = createMainWindow({
title: getAppName(channel, isDev),
isDev,
Expand All @@ -385,7 +403,8 @@ if (!hasSingleInstanceLock) {
sentryEnabled,
windowChromeHeight: WINDOW_CHROME_HEIGHT,
browserUserAgent: chromeLikeUserAgent,
appearance: resolveAppAppearance(),
appearance: reopenChrome.appearance,
sidebarTranslucency: reopenChrome.sidebarTranslucency,
...(process.env.VITE_DEV_SERVER_URL
? { devServerUrl: process.env.VITE_DEV_SERVER_URL }
: {}),
Expand Down
2 changes: 2 additions & 0 deletions src/main/sharedSettingsFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe("sharedSettingsFile", () => {
threadRemoveAction: "archive",
newThreadMode: "page",
homeScopeEnabled: true,
sidebarTranslucency: false,
autoShowTerminalPanel: true,
gitReviewMode: "panel",
prCreateMode: "dialog",
Expand Down Expand Up @@ -162,6 +163,7 @@ describe("sharedSettingsFile", () => {
threadRemoveAction: "archive",
newThreadMode: "page",
homeScopeEnabled: true,
sidebarTranslucency: false,
autoShowTerminalPanel: true,
gitReviewMode: "panel",
prCreateMode: "dialog",
Expand Down
1 change: 1 addition & 0 deletions src/main/window/createMainWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe("createMainWindow", () => {
windowChromeHeight: 32,
browserUserAgent: userAgent,
appearance: "dark",
sidebarTranslucency: false,
onClosed: vi.fn<() => void>(),
});

Expand Down
23 changes: 22 additions & 1 deletion src/main/window/createMainWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { dbGetState, dbSetState } from "../db";
import { BrowserWindow, screen, type RenderProcessGoneDetails } from "electron";
import type { LightcodeChannel } from "@/shared/channel";
import { installSessionPermissions } from "../browser/permissions";
import { supportsNativeWindowMaterial, syncNativeThemeForMaterial } from "./windowMaterial";

interface WindowBounds {
x?: number;
Expand Down Expand Up @@ -63,6 +64,8 @@ export interface CreateMainWindowOptions {
browserUserAgent: string;
/** Saved appearance, so the native window opens matching the theme. */
appearance: "light" | "dark";
/** Saved opt-in translucent ("liquid glass") sidebar, so the window opens with the material already applied. */
sidebarTranslucency: boolean;
onClosed(): void;
onClose?: (event: Electron.Event) => void;
onRendererProcessGone?: (details: RenderProcessGoneDetails) => void;
Expand All @@ -77,6 +80,20 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo
// setWindowChrome values, so the first frame doesn't flash a fixed palette.
const backgroundColor = isDark ? "#141416" : "#f1f1f4";
const symbolColor = isDark ? "#fafafa" : "#1f2937";
// macOS: always create the window transparent + vibrancy-capable so the glass
// sidebar can be toggled live (the renderer reveals/hides it purely via CSS —
// with glass off the opaque content simply covers the material). macOS can't
// turn an opaque window transparent at runtime, so the capability has to exist
// from creation. Windows acrylic is applied here for a flash-free first paint
// when glass is already on, and toggled live via setBackgroundMaterial.
const isMacOS = process.platform === "darwin";
const winGlassAtStart =
process.platform === "win32" && options.sidebarTranslucency && supportsNativeWindowMaterial();
if (options.sidebarTranslucency && supportsNativeWindowMaterial()) {
// Match the native appearance to the app theme so the material renders in the
// right light/dark variant from the first frame.
syncNativeThemeForMaterial(options.appearance);
}
const window = new BrowserWindow({
title: options.title,
show: false,
Expand All @@ -85,8 +102,12 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo
...(saved?.x != null && saved?.y != null ? { x: saved.x, y: saved.y } : {}),
minWidth: 540,
minHeight: 720,
backgroundColor,
backgroundColor: isMacOS || winGlassAtStart ? "#00000000" : backgroundColor,
autoHideMenuBar: true,
...(isMacOS
? { vibrancy: "sidebar" as const, visualEffectState: "active" as const, transparent: true }
: {}),
...(winGlassAtStart ? { backgroundMaterial: "acrylic" as const } : {}),
...(supportsTitleBarOverlay
? {
titleBarStyle: "hidden" as const,
Expand Down
44 changes: 44 additions & 0 deletions src/main/window/windowMaterial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { nativeTheme } from "electron";
import { release } from "node:os";

/**
* Native "liquid glass" window materials.
*
* The opt-in translucent sidebar relies on an OS-composited blur behind the
* window (macOS `NSVisualEffectView` vibrancy / Windows 11 DWM acrylic). On
* macOS these can only be revealed when the window is *created* transparent —
* an opaque window cannot be made transparent at runtime — so the material is
* applied once in {@link createMainWindow} and toggling the setting requires a
* relaunch. This module centralizes the OS capability check and native-theme sync.
*
* macOS 26 "Liquid Glass" (`NSGlassEffectView`) is not exposed by Electron, so
* the closest officially-supported material is `vibrancy: "sidebar"`, which the
* OS already re-skins toward the Tahoe look.
*/

/**
* Windows 11 22H2 (build 22621) is the first build with a stable DWM acrylic
* system backdrop; earlier Windows builds and Windows 10 have no usable native
* blur, so they fall back to the in-app CSS imitation.
*/
function isWindows11AcrylicCapable(): boolean {
if (process.platform !== "win32") {
return false;
}
const build = Number(release().split(".")[2] ?? "0");
return Number.isFinite(build) && build >= 22621;
}

/** Whether the current OS can render a native blur material behind the window. */
export function supportsNativeWindowMaterial(): boolean {
return process.platform === "darwin" || isWindows11AcrylicCapable();
}

/**
* Mirrors the app appearance onto the native theme so an active vibrancy/acrylic
* material renders in the matching light/dark variant. Without this it follows
* the OS appearance (e.g. a light app over a dark OS shows a dark frosted sidebar).
*/
export function syncNativeThemeForMaterial(appearance: "light" | "dark"): void {
nativeTheme.themeSource = appearance;
}
4 changes: 2 additions & 2 deletions src/renderer/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export function App() {
`[renderer] +${Date.now() - loadT0}ms: rendering spinner (hydrated=${storeHydrated})`,
);
return (
<AppProvider>
<AppProvider contentReady={false}>
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
<div className="flex flex-col items-center gap-4">
<PixelLoader size="lg" />
Expand All @@ -275,7 +275,7 @@ export function App() {
}

return (
<AppProvider>
<AppProvider contentReady>
<MainView storeHydrated={storeHydrated} loadT0={loadT0} />
<CommandPalette />
</AppProvider>
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/components/layout/OverlayShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export function OverlayShell(props: {
return (
<div
data-overlay-surface=""
// Present only while fully shown (not during fade-in/out). The glass-sidebar
// CSS hides the base app behind a translucent overlay, but only when the
// overlay is opaque — so on close the app reappears as the overlay fades,
// instead of revealing bare desktop underneath.
{...(visible ? { "data-overlay-visible": "" } : {})}
className={`${positionClass} flex flex-col bg-background transition-opacity duration-150 ${
visible ? "opacity-100" : "opacity-0"
}`}
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/components/layout/sidebarChrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,14 @@ export function panelHeaderTabIconButtonClass(active: boolean) {
/** Column shell (inset `px-2`); pair with `overlaySidebarSurfaceClass` for overlay/panel UIs. */
export const sidebarColumnLayoutClass = "flex h-full min-h-0 min-w-0 flex-col gap-3 px-2 pb-0 pt-0";

/** Primary surface for overlay and docked tool panels (matches main content / thread area). */
export const overlaySidebarSurfaceClass = "bg-[var(--content-background)]";
/**
* Primary surface for overlay and docked tool panels (matches main content / thread area).
* The `lightcode-overlay-surface` marker lets the translucent-sidebar CSS turn this
* transparent when it sits inside the sidebar column, so overlay sidebars get the same
* glass treatment as the main app sidebar (see styles.css).
*/
export const overlaySidebarSurfaceClass =
"lightcode-overlay-surface bg-[var(--content-background)]";

/** File editor, git, settings overlays, etc.: layout + background. */
export const overlaySidebarColumnClass = `${sidebarColumnLayoutClass} ${overlaySidebarSurfaceClass}`;
Expand Down
Loading