diff --git a/packages/app/src/components/titlebar-padding.test.ts b/packages/app/src/components/titlebar-padding.test.ts new file mode 100644 index 0000000000..1c0993cd0f --- /dev/null +++ b/packages/app/src/components/titlebar-padding.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { macTitlebarLeftPadding } from "./titlebar-padding" + +describe("macOS titlebar padding", () => { + test("reserves traffic light space while windowed", () => { + expect(macTitlebarLeftPadding(1, false)).toBe("84px") + expect(macTitlebarLeftPadding(2, false)).toBe("42px") + }) + + test("does not reserve traffic light space in fullscreen", () => { + expect(macTitlebarLeftPadding(1, true)).toBe("0px") + }) +}) diff --git a/packages/app/src/components/titlebar-padding.ts b/packages/app/src/components/titlebar-padding.ts new file mode 100644 index 0000000000..7334201055 --- /dev/null +++ b/packages/app/src/components/titlebar-padding.ts @@ -0,0 +1,6 @@ +const macTrafficLightsWidth = 84 + +export function macTitlebarLeftPadding(zoom: number, fullscreen: boolean) { + if (fullscreen) return "0px" + return `${macTrafficLightsWidth / zoom}px` +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index aa2f220e49..9cb286c6cb 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,6 +29,7 @@ import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { normalizeSessionInfo } from "@/utils/session" +import { macTitlebarLeftPadding } from "./titlebar-padding" type TauriDesktopWindow = { startDragging?: () => Promise @@ -237,8 +238,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }} style={{ "min-height": minHeight(), - // Keep native macOS traffic lights clear even when the desktop window is narrow. - "padding-left": mac() ? `${84 / zoom()}px` : 0, + // Keep visible native macOS traffic lights clear even when the desktop window is narrow. + "padding-left": mac() ? macTitlebarLeftPadding(zoom(), platform.windowFullscreen?.() ?? false) : 0, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index cf343bfa7e..4c374961a3 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -97,6 +97,9 @@ type PlatformBase = { /** Webview zoom level (desktop only) */ webviewZoom?: Accessor + /** Whether the native desktop window is fullscreen */ + windowFullscreen?: Accessor + /** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */ getPinchZoomEnabled?(): Promise | boolean diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688ac..5ecd4cb8c4 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -227,6 +227,11 @@ export function registerIpcHandlers(deps: Deps) { return win?.isFocused() ?? false }) + ipcMain.handle("get-window-fullscreen", (event: IpcMainInvokeEvent) => { + const win = BrowserWindow.fromWebContents(event.sender) + return win?.isFullScreen() ?? false + }) + ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => { const win = BrowserWindow.fromWebContents(event.sender) win?.focus() diff --git a/packages/desktop/src/main/windows.ts b/packages/desktop/src/main/windows.ts index 7a594b5302..516d05e634 100644 --- a/packages/desktop/src/main/windows.ts +++ b/packages/desktop/src/main/windows.ts @@ -219,6 +219,7 @@ export function createMainWindow(id: string = randomUUID()) { state.manage(win) registerWindow(win, id) + wireFullscreen(win) loadWindow(win, "index.html") wireZoom(win) @@ -472,6 +473,11 @@ function wireZoom(win: BrowserWindow) { }) } +function wireFullscreen(win: BrowserWindow) { + win.on("enter-full-screen", () => win.webContents.send("window-fullscreen-changed", true)) + win.on("leave-full-screen", () => win.webContents.send("window-fullscreen-changed", false)) +} + function clampZoom(value: number) { return Math.min(Math.max(value, minZoomLevel), maxZoomLevel) } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 87eac1fa80..1a0056dfe7 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -100,6 +100,12 @@ const api: ElectronAPI = { readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"), showNotification: (title, body) => ipcRenderer.send("show-notification", title, body), getWindowFocused: () => ipcRenderer.invoke("get-window-focused"), + getWindowFullscreen: () => ipcRenderer.invoke("get-window-fullscreen"), + onWindowFullscreenChanged: (cb) => { + const handler = (_: unknown, fullscreen: boolean) => cb(fullscreen) + ipcRenderer.on("window-fullscreen-changed", handler) + return () => ipcRenderer.removeListener("window-fullscreen-changed", handler) + }, setWindowFocus: () => ipcRenderer.invoke("set-window-focus"), showWindow: () => ipcRenderer.invoke("show-window"), relaunch: () => ipcRenderer.send("relaunch"), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 5fbace72c7..ea201af63e 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -91,6 +91,8 @@ export type ElectronAPI = { readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null> showNotification: (title: string, body?: string) => void getWindowFocused: () => Promise + getWindowFullscreen: () => Promise + onWindowFullscreenChanged: (cb: (fullscreen: boolean) => void) => () => void setWindowFocus: () => Promise showWindow: () => Promise relaunch: () => void diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index b7d2d9a74d..fa4c7f325e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -30,6 +30,10 @@ import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" +const [windowFullscreen, setWindowFullscreen] = createSignal(false) +window.api.onWindowFullscreenChanged(setWindowFullscreen) +void window.api.getWindowFullscreen().then(setWindowFullscreen) + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { throw new Error(t("error.dev.rootNotFound")) @@ -294,6 +298,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { webviewZoom, + windowFullscreen, + getPinchZoomEnabled: () => window.api.getPinchZoomEnabled(), setPinchZoomEnabled,