diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 9017356ab5..8222c62d98 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -229,6 +229,18 @@ pub fn run() {
});
}
+ // Windows draws a separate native title bar that cannot be
+ // tinted to match the app surface. Go undecorated and let
+ // the web chrome (`AppTopChrome`) own the whole top edge,
+ // matching the macOS Overlay style. `set_shadow` keeps the
+ // drop shadow and rounded corners an undecorated window
+ // otherwise loses.
+ #[cfg(target_os = "windows")]
+ {
+ let _ = window.set_decorations(false);
+ let _ = window.set_shadow(true);
+ }
+
#[cfg(not(target_os = "macos"))]
{
reveal_initial_window(&window);
diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx
index 845c403b8c..abee17fda6 100644
--- a/desktop/src/app/AppTopChrome.tsx
+++ b/desktop/src/app/AppTopChrome.tsx
@@ -6,7 +6,8 @@ import {
PanelLeftOpen,
} from "lucide-react";
-import { isMacPlatform } from "@/shared/lib/platform";
+import { isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform";
+import { WindowControls } from "@/shared/ui/WindowControls";
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
@@ -81,6 +82,9 @@ export function AppTopChrome({
: "pl-[80px]"
: "pl-3";
const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null;
+ // Windows runs undecorated (see `lib.rs`), so the app draws its own caption
+ // buttons. They must sit flush against the window edge, hence no right pad.
+ const showWindowControls = isWindowsPlatform() && !isFullscreen;
React.useEffect(() => {
const topChrome = topChromeRef.current;
@@ -99,7 +103,8 @@ export function AppTopChrome({
+ {showWindowControls ? : null}
);
}
diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts
index 42e7f5b944..4e43e850d2 100644
--- a/desktop/src/shared/lib/platform.ts
+++ b/desktop/src/shared/lib/platform.ts
@@ -12,6 +12,24 @@ export function isMacPlatform(): boolean {
return /mac|iphone|ipad|ipod/i.test(navigator.platform);
}
+/**
+ * Returns true on Windows desktops.
+ *
+ * WebView2 reports `navigator.platform` as `Win32` today, but the property is
+ * deprecated, so the user agent backs it up. This gates the app-drawn caption
+ * buttons on an undecorated window: a false negative would leave that window
+ * with no way to close, so it does not rest on one deprecated signal.
+ */
+export function isWindowsPlatform(): boolean {
+ if (typeof navigator === "undefined") {
+ return false;
+ }
+
+ return (
+ /win/i.test(navigator.platform) || /windows/i.test(navigator.userAgent)
+ );
+}
+
/** Returns true on Linux desktops (excludes Android). */
export function isLinuxPlatform(): boolean {
if (typeof navigator === "undefined") {
diff --git a/desktop/src/shared/ui/WindowControls.tsx b/desktop/src/shared/ui/WindowControls.tsx
new file mode 100644
index 0000000000..3cfa97db29
--- /dev/null
+++ b/desktop/src/shared/ui/WindowControls.tsx
@@ -0,0 +1,133 @@
+import * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+
+/**
+ * Windows caption buttons for the undecorated main window.
+ *
+ * The window is created without native decorations on Windows (see
+ * `lib.rs`), so the app draws minimize/maximize/close itself. Sizing follows
+ * the Fluent caption metrics (46x32 hit target, 10px glyph) rather than the
+ * app's rem scale: like the macOS traffic lights these must not grow or shrink
+ * with Cmd +/- text zoom. Deliberate exception to the rem-first rule.
+ */
+
+const CAPTION_BUTTON_CLASS =
+ "inline-flex h-[32px] w-[46px] items-center justify-center text-sidebar-foreground/75 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
+
+function MinimizeGlyph() {
+ return (
+
+ );
+}
+
+function MaximizeGlyph({ maximized }: { maximized: boolean }) {
+ if (maximized) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+function CloseGlyph() {
+ return (
+
+ );
+}
+
+export function WindowControls() {
+ const [maximized, setMaximized] = React.useState(false);
+ const windowRef = React.useRef<{
+ minimize: () => Promise;
+ toggleMaximize: () => Promise;
+ close: () => Promise;
+ isMaximized: () => Promise;
+ onResized: (handler: () => void) => Promise<() => void>;
+ } | null>(null);
+
+ React.useEffect(() => {
+ let unlisten: (() => void) | undefined;
+ let cancelled = false;
+
+ void (async () => {
+ const { getCurrentWindow } = await import("@tauri-apps/api/window");
+ if (cancelled) {
+ return;
+ }
+
+ const appWindow = getCurrentWindow();
+ windowRef.current = appWindow;
+ setMaximized(await appWindow.isMaximized());
+
+ unlisten = await appWindow.onResized(() => {
+ void appWindow.isMaximized().then(setMaximized);
+ });
+ })();
+
+ return () => {
+ cancelled = true;
+ unlisten?.();
+ };
+ }, []);
+
+ return (
+ // Not a drag region: caption buttons must receive clicks, and the
+ // surrounding chrome already carries `data-tauri-drag-region`.
+