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
71 changes: 66 additions & 5 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ import {
shell,
systemPreferences,
} from "electron";
import type { MenuItemConstructorOptions } from "electron";
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from "electron";
import * as Effect from "effect/Effect";
import type {
DesktopTheme,
DesktopUpdateActionResult,
DesktopUpdateState,
DesktopWindowState,
} from "@t3tools/contracts";
import { autoUpdater } from "electron-updater";

Expand Down Expand Up @@ -61,6 +62,7 @@ import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runti
import { DesktopBrowserManager } from "./browserManager";
import { registerBrowserIpcHandlers, sendBrowserState } from "./browserIpc";
import { BrowserUsePipeServer } from "./browserUsePipeServer";
import { resolveDesktopWindowChrome, resolveDesktopWindowState } from "./windowChrome";

syncShellEnvironment();

Expand All @@ -76,6 +78,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const WINDOW_STATE_CHANNEL = "desktop:window-state";
const WINDOW_GET_STATE_CHANNEL = "desktop:window-get-state";
const WINDOW_MINIMIZE_CHANNEL = "desktop:window-minimize";
const WINDOW_TOGGLE_MAXIMIZE_CHANNEL = "desktop:window-toggle-maximize";
const WINDOW_CLOSE_CHANNEL = "desktop:window-close";
const NOTIFICATIONS_IS_SUPPORTED_CHANNEL = "desktop:notifications-is-supported";
const NOTIFICATIONS_SHOW_CHANNEL = "desktop:notifications-show";
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".dpcode");
Expand Down Expand Up @@ -978,6 +985,23 @@ function emitUpdateState(): void {
}
}

function resolveIpcOwnerWindow(event: IpcMainInvokeEvent | undefined): BrowserWindow | null {
return (
(event ? BrowserWindow.fromWebContents(event.sender) : null) ??
BrowserWindow.getFocusedWindow() ??
mainWindow
);
}

function emitWindowState(window: BrowserWindow | null): void {
if (!window || window.isDestroyed()) {
return;
}

const state: DesktopWindowState = resolveDesktopWindowState(window);
window.webContents.send(WINDOW_STATE_CHANNEL, state);
}

function setUpdateState(patch: Partial<DesktopUpdateState>): void {
updateState = { ...updateState, ...patch };
emitUpdateState();
Expand Down Expand Up @@ -1604,6 +1628,40 @@ function registerIpcHandlers(): void {
} satisfies DesktopUpdateActionResult;
});

ipcMain.removeHandler(WINDOW_GET_STATE_CHANNEL);
ipcMain.handle(WINDOW_GET_STATE_CHANNEL, async (event) => {
const window = resolveIpcOwnerWindow(event);
return window ? resolveDesktopWindowState(window) : { isMaximized: false };
});

ipcMain.removeHandler(WINDOW_MINIMIZE_CHANNEL);
ipcMain.handle(WINDOW_MINIMIZE_CHANNEL, async (event) => {
resolveIpcOwnerWindow(event)?.minimize();
});

ipcMain.removeHandler(WINDOW_TOGGLE_MAXIMIZE_CHANNEL);
ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE_CHANNEL, async (event) => {
const window = resolveIpcOwnerWindow(event);
if (!window) {
return { isMaximized: false };
}

if (window.isFullScreen()) {
window.setFullScreen(false);
} else if (window.isMaximized()) {
window.unmaximize();
} else {
window.maximize();
}

return resolveDesktopWindowState(window);
});

ipcMain.removeHandler(WINDOW_CLOSE_CHANNEL);
ipcMain.handle(WINDOW_CLOSE_CHANNEL, async (event) => {
resolveIpcOwnerWindow(event)?.close();
});

ipcMain.removeHandler(NOTIFICATIONS_IS_SUPPORTED_CHANNEL);
ipcMain.handle(NOTIFICATIONS_IS_SUPPORTED_CHANNEL, async () => Notification.isSupported());

Expand Down Expand Up @@ -1655,10 +1713,7 @@ function createWindow(): BrowserWindow {
autoHideMenuBar: true,
...getIconOption(),
title: APP_DISPLAY_NAME,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
vibrancy: "under-window",
visualEffectState: "active",
...resolveDesktopWindowChrome(process.platform),
backgroundColor: "#00000000",
webPreferences: {
preload: Path.join(__dirname, "preload.js"),
Expand Down Expand Up @@ -1720,10 +1775,16 @@ function createWindow(): BrowserWindow {
window.webContents.on("did-finish-load", () => {
window.setTitle(APP_DISPLAY_NAME);
emitUpdateState();
emitWindowState(window);
});
window.once("ready-to-show", () => {
window.show();
});
const emitCurrentWindowState = () => emitWindowState(window);
window.on("maximize", emitCurrentWindowState);
window.on("unmaximize", emitCurrentWindowState);
window.on("enter-full-screen", emitCurrentWindowState);
window.on("leave-full-screen", emitCurrentWindowState);

if (isDevelopment) {
void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
const WINDOW_STATE_CHANNEL = "desktop:window-state";
const WINDOW_GET_STATE_CHANNEL = "desktop:window-get-state";
const WINDOW_MINIMIZE_CHANNEL = "desktop:window-minimize";
const WINDOW_TOGGLE_MAXIMIZE_CHANNEL = "desktop:window-toggle-maximize";
const WINDOW_CLOSE_CHANNEL = "desktop:window-close";
const NOTIFICATIONS_IS_SUPPORTED_CHANNEL = "desktop:notifications-is-supported";
const NOTIFICATIONS_SHOW_CHANNEL = "desktop:notifications-show";
const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
Expand Down Expand Up @@ -56,6 +61,23 @@ contextBridge.exposeInMainWorld("desktopBridge", {
ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener);
};
},
window: {
minimize: () => ipcRenderer.invoke(WINDOW_MINIMIZE_CHANNEL),
toggleMaximize: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE_CHANNEL),
close: () => ipcRenderer.invoke(WINDOW_CLOSE_CHANNEL),
getState: () => ipcRenderer.invoke(WINDOW_GET_STATE_CHANNEL),
onState: (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);
};
},
},
notifications: {
isSupported: () => ipcRenderer.invoke(NOTIFICATIONS_IS_SUPPORTED_CHANNEL),
show: (input) => ipcRenderer.invoke(NOTIFICATIONS_SHOW_CHANNEL, input),
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/windowChrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import { resolveDesktopWindowChrome, resolveDesktopWindowState } from "./windowChrome";

describe("resolveDesktopWindowChrome", () => {
it("keeps native inset traffic lights on macOS", () => {
expect(resolveDesktopWindowChrome("darwin")).toEqual({
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
vibrancy: "under-window",
visualEffectState: "active",
});
});

it("switches Windows and Linux to a hidden custom title bar", () => {
expect(resolveDesktopWindowChrome("win32")).toEqual({
titleBarStyle: "hidden",
});
expect(resolveDesktopWindowChrome("linux")).toEqual({
titleBarStyle: "hidden",
});
});
});

describe("resolveDesktopWindowState", () => {
it("treats maximized windows as maximized", () => {
expect(
resolveDesktopWindowState({
isMaximized: () => true,
isFullScreen: () => false,
}),
).toEqual({
isMaximized: true,
});
});

it("treats fullscreen windows as maximized for renderer chrome", () => {
expect(
resolveDesktopWindowState({
isMaximized: () => false,
isFullScreen: () => true,
}),
).toEqual({
isMaximized: true,
});
});
});
30 changes: 30 additions & 0 deletions apps/desktop/src/windowChrome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { BrowserWindow, BrowserWindowConstructorOptions } from "electron";
import type { DesktopWindowState } from "@t3tools/contracts";

export function resolveDesktopWindowChrome(
platform: NodeJS.Platform,
): Pick<
BrowserWindowConstructorOptions,
"titleBarStyle" | "trafficLightPosition" | "vibrancy" | "visualEffectState"
> {
if (platform === "darwin") {
return {
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 18 },
vibrancy: "under-window",
visualEffectState: "active",
};
}

return {
titleBarStyle: "hidden",
};
}

export function resolveDesktopWindowState(
window: Pick<BrowserWindow, "isMaximized" | "isFullScreen">,
): DesktopWindowState {
return {
isMaximized: window.isMaximized() || window.isFullScreen(),
};
}
40 changes: 32 additions & 8 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ import {
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor";
import { PullRequestThreadDialog } from "./PullRequestThreadDialog";
import { ChatHeader } from "./chat/ChatHeader";
import { DesktopWindowControls } from "./chat/DesktopWindowControls";
import { ChatTranscriptPane } from "./chat/ChatTranscriptPane";
import { ComposerSlashStatusDialog } from "./chat/ComposerSlashStatusDialog";
import { ExpandedImagePreview } from "./chat/ExpandedImagePreview";
Expand Down Expand Up @@ -327,8 +328,12 @@ import {
resolveDiffEnvironmentState,
resolveThreadEnvironmentMode,
} from "../lib/threadEnvironment";
import { supportsCustomDesktopTitleBar } from "../lib/desktopWindow";
import { buildModelSelection, buildNextProviderOptions } from "../providerModelOptions";
import { waitForRecoverableProjectForDuplicateCreate } from "../lib/projectCreateRecovery";
import {
isDuplicateProjectCreateError,
waitForRecoverableProjectForDuplicateCreate,
} from "../lib/projectCreateRecovery";

const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`;
Expand Down Expand Up @@ -1006,6 +1011,7 @@ export default function ChatView({
? activeProject?.folderName
: activeProject?.name;
const isChatProject = isHomeChatContainer;
const usesCustomDesktopTitleBar = supportsCustomDesktopTitleBar();
const activeProjectScripts =
activeProject?.kind === "project" ? activeProject.scripts : undefined;
const threadLineageThreads = useStore(
Expand Down Expand Up @@ -4584,12 +4590,17 @@ export default function ChatView({
} catch (error) {
const description =
error instanceof Error ? error.message : "Failed to create the selected project.";
if (!isDuplicateProjectCreateError(description)) {
throw error;
}

// If the server already knows this workspace root, reuse that project and continue.
const { snapshot, project: recoveredProject } =
await waitForRecoverableProjectForDuplicateCreate({
message: description,
workspaceRoot: firstSendTarget.creation.workspaceRoot,
loadSnapshot: () => api.orchestration.getSnapshot().catch(() => null),
repairSnapshot: () => api.orchestration.repairState().catch(() => null),
});
Comment on lines 4600 to 4604
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate repair fallback to duplicate project-create errors

This path now always passes repairSnapshot into duplicate-recovery, but the call is made for any project.create failure in this catch block. When the error is not a duplicate-workspace invariant, we still do recovery polling and then call api.orchestration.repairState() before rethrowing, which adds avoidable delay and can mutate state on unrelated failures; this should be conditioned on isDuplicateProjectCreateError (or only provide repairSnapshot in that case).

Useful? React with 👍 / 👎.

if (!snapshot || !recoveredProject) {
throw error;
Expand Down Expand Up @@ -6230,12 +6241,16 @@ export default function ChatView({
{isElectron && (
<div
className={cn(
"drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5",
settings.sidebarSide === "right" && "pl-[90px]",
"drag-region flex h-[52px] shrink-0 items-center border-b border-border",
usesCustomDesktopTitleBar ? "pl-5 pr-0" : "px-5",
!usesCustomDesktopTitleBar && settings.sidebarSide === "right" && "pl-[90px]",
)}
>
<SidebarHeaderTrigger className="size-7 shrink-0" />
<span className="text-xs text-muted-foreground/50">No active thread</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<SidebarHeaderTrigger className="size-7 shrink-0" />
<span className="text-xs text-muted-foreground/50">No active thread</span>
</div>
<DesktopWindowControls />
</div>
)}
<div className="flex flex-1 items-center justify-center">
Expand Down Expand Up @@ -6891,9 +6906,17 @@ export default function ChatView({
{/* 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 && settings.sidebarSide === "right" && "pl-[90px] sm:pl-[90px]",
"border-b border-border",
isElectron
? cn(
"drag-region flex h-[52px] items-center",
usesCustomDesktopTitleBar ? "pl-5 pr-0" : "px-5",
)
: "px-3 py-2 sm:px-5 sm:py-3",
isElectron &&
!usesCustomDesktopTitleBar &&
settings.sidebarSide === "right" &&
"pl-[90px]",
)}
>
<ChatHeader
Expand Down Expand Up @@ -6955,6 +6978,7 @@ export default function ChatView({
onNavigateToThread={onNavigateToThread}
onRenameThread={() => setRenameDialogOpen(true)}
/>
<DesktopWindowControls />
</header>

<RenameThreadDialog
Expand Down
Loading