Skip to content
Open
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
80 changes: 78 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type { MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
DesktopTheme,
DesktopWindowState,
DesktopUpdateActionResult,
DesktopUpdateCheckResult,
DesktopUpdateState,
Expand Down Expand Up @@ -60,6 +61,11 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const MINIMIZE_WINDOW_CHANNEL = "desktop:window-minimize";
const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:window-toggle-maximize";
const CLOSE_WINDOW_CHANNEL = "desktop:window-close";
const GET_WINDOW_STATE_CHANNEL = "desktop:window-state-get";
const WINDOW_STATE_CHANNEL = "desktop:window-state";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
const STATE_DIR = Path.join(BASE_DIR, "userdata");
const DESKTOP_SCHEME = "t3";
Expand Down Expand Up @@ -773,6 +779,29 @@ function emitUpdateState(): void {
}
}

function resolveEventWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null {
const window = BrowserWindow.fromWebContents(event.sender);
if (window && !window.isDestroyed()) {
return window;
}
if (mainWindow && !mainWindow.isDestroyed()) {
return mainWindow;
}
const fallback = BrowserWindow.getAllWindows().find((entry) => !entry.isDestroyed());
return fallback ?? null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Close handler fallback may close wrong window

Medium Severity

The resolveEventWindow helper, used by window IPC handlers, includes a fallback to mainWindow or any other active window if the event sender's window cannot be resolved. For the CLOSE_WINDOW_CHANNEL handler, this fallback can cause an unrelated window to close, risking unexpected data loss.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2be07d2. Configure here.


function toWindowState(window: BrowserWindow): DesktopWindowState {
return {
maximized: window.isMaximized(),
};
}

function emitWindowState(window: BrowserWindow): void {
if (window.isDestroyed()) return;
window.webContents.send(WINDOW_STATE_CHANNEL, toWindowState(window));
}

function setUpdateState(patch: Partial<DesktopUpdateState>): void {
updateState = { ...updateState, ...patch };
emitUpdateState();
Expand Down Expand Up @@ -1172,6 +1201,45 @@ function registerIpcHandlers(): void {
event.returnValue = backendWsUrl;
});

ipcMain.removeHandler(MINIMIZE_WINDOW_CHANNEL);
ipcMain.handle(MINIMIZE_WINDOW_CHANNEL, async (event) => {
const window = resolveEventWindow(event);
if (!window) return;
window.minimize();
});

ipcMain.removeHandler(TOGGLE_MAXIMIZE_WINDOW_CHANNEL);
ipcMain.handle(TOGGLE_MAXIMIZE_WINDOW_CHANNEL, async (event) => {
const window = resolveEventWindow(event);
if (!window) {
return { maximized: false } satisfies DesktopWindowState;
}
if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}
const nextState = toWindowState(window);
emitWindowState(window);
return nextState;
});

ipcMain.removeHandler(CLOSE_WINDOW_CHANNEL);
ipcMain.handle(CLOSE_WINDOW_CHANNEL, async (event) => {
const window = resolveEventWindow(event);
if (!window) return;
window.close();
});

ipcMain.removeHandler(GET_WINDOW_STATE_CHANNEL);
ipcMain.handle(GET_WINDOW_STATE_CHANNEL, async (event) => {
const window = resolveEventWindow(event);
if (!window) {
return { maximized: false } satisfies DesktopWindowState;
}
return toWindowState(window);
});

ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
Expand Down Expand Up @@ -1347,8 +1415,9 @@ function createWindow(): BrowserWindow {
autoHideMenuBar: true,
...getIconOption(),
title: APP_DISPLAY_NAME,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
...(process.platform === "win32"
? { frame: false }
: { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 16, y: 18 } }),
webPreferences: {
preload: Path.join(__dirname, "preload.js"),
contextIsolation: true,
Expand Down Expand Up @@ -1400,6 +1469,13 @@ function createWindow(): BrowserWindow {
window.webContents.on("did-finish-load", () => {
window.setTitle(APP_DISPLAY_NAME);
emitUpdateState();
emitWindowState(window);
});
window.on("maximize", () => {
emitWindowState(window);
});
window.on("unmaximize", () => {
emitWindowState(window);
});
window.once("ready-to-show", () => {
window.show();
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
const MINIMIZE_WINDOW_CHANNEL = "desktop:window-minimize";
const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:window-toggle-maximize";
const CLOSE_WINDOW_CHANNEL = "desktop:window-close";
const GET_WINDOW_STATE_CHANNEL = "desktop:window-state-get";
const WINDOW_STATE_CHANNEL = "desktop:window-state";

contextBridge.exposeInMainWorld("desktopBridge", {
getWsUrl: () => {
Expand Down Expand Up @@ -50,4 +55,19 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener);
};
},
minimizeWindow: () => ipcRenderer.invoke(MINIMIZE_WINDOW_CHANNEL),
toggleMaximizeWindow: () => ipcRenderer.invoke(TOGGLE_MAXIMIZE_WINDOW_CHANNEL),
closeWindow: () => ipcRenderer.invoke(CLOSE_WINDOW_CHANNEL),
getWindowState: () => ipcRenderer.invoke(GET_WINDOW_STATE_CHANNEL),
onWindowState: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => {
if (typeof state !== "object" || state === null) return;
listener(state as Parameters<typeof listener>[0]);
};

ipcRenderer.on(WINDOW_STATE_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANNEL, wrappedListener);
};
},
} satisfies DesktopBridge);
27 changes: 24 additions & 3 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { useGitStatus } from "~/lib/gitStatusState";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { isElectron } from "../env";
import { isElectron, isWindowsElectron } from "../env";
import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
import {
clampCollapsedComposerCursor,
Expand Down Expand Up @@ -154,6 +154,7 @@ import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./Compose
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { MessagesTimeline } from "./chat/MessagesTimeline";
import { ChatHeader } from "./chat/ChatHeader";
import { DesktopTitleBar } from "./DesktopTitleBar";
import { ContextWindowMeter } from "./chat/ContextWindowMeter";
import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview";
import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker";
Expand Down Expand Up @@ -3910,7 +3911,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
</div>
</header>
)}
{isElectron && (
{isWindowsElectron && (
<DesktopTitleBar
title="Threads"
subtitle="No active thread"
contextLabel="Workspace"
contextValue="Threads"
showContextChip={false}
/>
)}
{isElectron && !isWindowsElectron && (
<div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">
<span className="text-xs text-muted-foreground/50">No active thread</span>
</div>
Expand All @@ -3926,11 +3936,22 @@ export default function ChatView({ threadId }: ChatViewProps) {

return (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden bg-background">
{isWindowsElectron && (
<DesktopTitleBar
title={activeThread.title}
contextLabel="Project"
contextValue={activeProject?.name ?? "None"}
showContextChip={false}
{...(activeProject?.name ? { subtitle: activeProject.name } : {})}
/>
)}
{/* Top bar */}
<header
className={cn(
"border-b border-border px-3 sm:px-5",
isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",
isElectron && !isWindowsElectron
? "drag-region flex h-[52px] items-center"
: "py-2 sm:py-3",
)}
>
<ChatHeader
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/DesktopTitleBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ReactNode } from "react";

import { cn } from "~/lib/utils";

import { DesktopWindowControls } from "./DesktopWindowControls";

interface DesktopTitleBarProps {
title: string;
subtitle?: string;
contextLabel?: string;
contextValue?: string;
showContextChip?: boolean;
trailing?: ReactNode;
className?: string;
showWindowControls?: boolean;
}

export function DesktopTitleBar(props: DesktopTitleBarProps) {
const showContextChip = props.showContextChip ?? true;
const contextLabel = props.contextLabel ?? "Workspace";
const contextValue = props.contextValue;

return (
<div
className={cn(
"drag-region relative flex h-[44px] shrink-0 items-center border-b border-border/70 bg-[linear-gradient(90deg,color-mix(in_srgb,var(--background)_95%,var(--color-black)_5%)_0%,var(--background)_65%,color-mix(in_srgb,var(--background)_94%,var(--color-black)_6%)_100%)] px-3",
props.className,
)}
>
{showContextChip ? (
<div className="min-w-0 max-w-[40%] truncate">
<div className="inline-flex items-center gap-2 rounded-md border border-border/60 bg-card/70 px-2 py-1 text-[11px] leading-none">
<span className="inline-flex size-4 items-center justify-center rounded-sm bg-foreground text-[9px] font-semibold text-background">
T3
</span>
<span className="truncate font-medium tracking-tight text-foreground/85">
{contextLabel}
</span>
{contextValue ? (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.14em] uppercase text-muted-foreground">
{contextValue}
</span>
) : null}
</div>
</div>
) : null}

<div className="pointer-events-none absolute inset-0 flex min-w-0 items-center justify-center px-[8.5rem]">
<div className="min-w-0 text-center">
<div className="truncate text-[12px] font-medium text-foreground/90">{props.title}</div>
{props.subtitle ? (
<div className="truncate text-[10px] text-muted-foreground/85">{props.subtitle}</div>
) : null}
</div>
</div>

<div className="ms-auto flex shrink-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
{props.trailing}
{props.showWindowControls === false ? null : <DesktopWindowControls />}
</div>
</div>
);
}
38 changes: 38 additions & 0 deletions apps/web/src/components/DesktopWindowControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";

import { DesktopWindowControls } from "./DesktopWindowControls";

function setDesktopBridge(value: unknown) {
vi.stubGlobal("window", {
desktopBridge: value,
});
}

describe("DesktopWindowControls", () => {
afterEach(() => {
vi.unstubAllGlobals();
});

it("does not render controls when desktop bridge APIs are unavailable", () => {
setDesktopBridge(undefined);
const html = renderToStaticMarkup(<DesktopWindowControls />);

expect(html).toBe("");
});

it("renders controls when desktop bridge APIs are available", () => {
setDesktopBridge({
minimizeWindow: async () => undefined,
toggleMaximizeWindow: async () => ({ maximized: false }),
closeWindow: async () => undefined,
getWindowState: async () => ({ maximized: false }),
onWindowState: () => () => undefined,
});
const html = renderToStaticMarkup(<DesktopWindowControls />);

expect(html).toContain("Minimize window");
expect(html).toContain("Maximize window");
expect(html).toContain("Close window");
});
});
Loading
Loading