feat(desktop): immersive mode hides traffic lights for full-screen modals

Full-screen modals (create-workspace) covered the app titlebar, so the
Back button landed on top of the macOS traffic lights — where native
hit-test always wins and the button couldn't be clicked. The modal
also swallowed the window's drag region.

Introduce a desktop IPC channel window:setImmersive that calls
BrowserWindow.setWindowButtonVisibility, exposed through the existing
desktopAPI preload bridge. A small useImmersiveMode() hook in
@multica/views/platform toggles it for the component's lifetime and
is a no-op on web / non-macOS.

CreateWorkspaceModal now:
- calls useImmersiveMode() so traffic lights disappear while it's open
- adds a transparent top h-10 drag strip to restore window dragging
- moves the Back button from top-6 left-6 to top-12 left-12 with an
  explicit no-drag region so clicks always reach it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-04-14 19:41:49 +08:00
parent 7ade4b432d
commit 7dad45d444
7 changed files with 61 additions and 2 deletions

View File

@@ -113,6 +113,14 @@ if (!gotTheLock) {
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (create-workspace, onboarding) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;
mainWindow?.setWindowButtonVisibility(!immersive);
});
createWindow();
setupAutoUpdater(() => mainWindow);

View File

@@ -5,6 +5,8 @@ interface DesktopAPI {
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
}
interface UpdaterAPI {

View File

@@ -13,6 +13,9 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
};
const updaterAPI = {

View File

@@ -2,6 +2,7 @@
import { useRef, useState } from "react";
import { useNavigation } from "../navigation";
import { useImmersiveMode } from "../platform";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
@@ -24,6 +25,11 @@ import {
} from "../workspace/slug";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
// we hide the traffic lights for its lifetime so the Back button in the top-
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
useImmersiveMode();
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
@@ -87,10 +93,20 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
showCloseButton={false}
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
>
{/* Top drag region — restores window-drag ability that the full-screen
modal would otherwise swallow. Transparent; web browsers ignore the
-webkit-app-region property, so this is safe cross-platform. */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-10"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<Button
variant="ghost"
size="sm"
className="absolute top-6 left-6 text-muted-foreground"
className="absolute top-12 left-12 text-muted-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />

View File

@@ -32,7 +32,8 @@
"./search": "./search/index.ts",
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts"
"./onboarding": "./onboarding/index.ts",
"./platform": "./platform/index.ts"
},
"dependencies": {
"@base-ui/react": "^1.3.0",

View File

@@ -0,0 +1 @@
export { useImmersiveMode } from "./use-immersive-mode";

View File

@@ -0,0 +1,28 @@
import { useEffect } from "react";
type ImmersiveCapableAPI = {
setImmersiveMode?: (immersive: boolean) => Promise<void> | void;
};
function getDesktopAPI(): ImmersiveCapableAPI | undefined {
if (typeof window === "undefined") return undefined;
return (window as unknown as { desktopAPI?: ImmersiveCapableAPI }).desktopAPI;
}
/**
* Enter "immersive" mode for the lifetime of the component that calls it.
*
* On macOS desktop this hides the traffic-light window controls so full-screen
* modals (create-workspace, onboarding, etc.) can place UI in the top-left
* corner without fighting the native controls' hit-test. On web or non-macOS
* desktop this is a no-op.
*/
export function useImmersiveMode(): void {
useEffect(() => {
const api = getDesktopAPI();
api?.setImmersiveMode?.(true);
return () => {
api?.setImmersiveMode?.(false);
};
}, []);
}