diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 002b8ed2..f9853fd8 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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"; @@ -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(); @@ -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"); @@ -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): void { updateState = { ...updateState, ...patch }; emitUpdateState(); @@ -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()); @@ -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"), @@ -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); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 0decb871..779b4379 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -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; @@ -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[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), diff --git a/apps/desktop/src/windowChrome.test.ts b/apps/desktop/src/windowChrome.test.ts new file mode 100644 index 00000000..c70cf98f --- /dev/null +++ b/apps/desktop/src/windowChrome.test.ts @@ -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, + }); + }); +}); diff --git a/apps/desktop/src/windowChrome.ts b/apps/desktop/src/windowChrome.ts new file mode 100644 index 00000000..3eb59506 --- /dev/null +++ b/apps/desktop/src/windowChrome.ts @@ -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, +): DesktopWindowState { + return { + isMaximized: window.isMaximized() || window.isFullScreen(), + }; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 57949fe6..0424cb6f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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"; @@ -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`; @@ -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( @@ -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), }); if (!snapshot || !recoveredProject) { throw error; @@ -6230,12 +6241,16 @@ export default function ChatView({ {isElectron && (
- - No active thread +
+ + No active thread +
+
)}
@@ -6891,9 +6906,17 @@ export default function ChatView({ {/* Top bar */}
setRenameDialogOpen(true)} /> +
); const PROJECT_CONTEXT_MENU_DELETE_THREADS_ICON = renderToStaticMarkup(); -function wait(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function threadJumpLabelMapsEqual( left: ReadonlyMap, right: ReadonlyMap, @@ -1135,6 +1134,7 @@ export default function Sidebar() { const [renamingWorkspaceId, setRenamingWorkspaceId] = useState(null); const [renamingWorkspaceTitle, setRenamingWorkspaceTitle] = useState(""); const [installingDesktopUpdate, setInstallingDesktopUpdate] = useState(false); + const usesCustomDesktopTitleBar = supportsCustomDesktopTitleBar(); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); @@ -1432,32 +1432,14 @@ export default function Sidebar() { ): Promise<{ project: OrchestrationReadModel["projects"][number] | null; snapshot: OrchestrationReadModel | null; - }> => { - let latestSnapshot: OrchestrationReadModel | null = null; - - for (let attempt = 1; attempt <= ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS; attempt += 1) { - const snapshot = await api.orchestration.getSnapshot().catch(() => null); - if (snapshot) { - latestSnapshot = snapshot; - const project = - snapshot.projects.find( - (candidate) => candidate.id === projectId && candidate.deletedAt === null, - ) ?? null; - if (project) { - return { project, snapshot }; - } - } - - if (attempt < ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS) { - await wait(ADD_PROJECT_SNAPSHOT_CATCH_UP_DELAY_MS * attempt); - } - } - - return { - project: null, - snapshot: latestSnapshot, - }; - }, + }> => + waitForRecoverableProjectInReadModel({ + projectId, + loadSnapshot: () => api.orchestration.getSnapshot().catch(() => null), + repairSnapshot: () => api.orchestration.repairState().catch(() => null), + maxAttempts: ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS, + delayMs: ADD_PROJECT_SNAPSHOT_CATCH_UP_DELAY_MS, + }), [], ); @@ -1468,35 +1450,14 @@ export default function Sidebar() { ): Promise<{ project: OrchestrationReadModel["projects"][number] | null; snapshot: OrchestrationReadModel | null; - }> => { - let latestSnapshot: OrchestrationReadModel | null = null; - - for (let attempt = 1; attempt <= ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS; attempt += 1) { - const snapshot = await api.orchestration.getSnapshot().catch(() => null); - if (snapshot) { - latestSnapshot = snapshot; - const project = - snapshot.projects.find( - (candidate) => - candidate.deletedAt === null && - findWorkspaceRootMatch([candidate], workspaceRoot, (item) => item.workspaceRoot) !== - undefined, - ) ?? null; - if (project) { - return { project, snapshot }; - } - } - - if (attempt < ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS) { - await wait(ADD_PROJECT_SNAPSHOT_CATCH_UP_DELAY_MS * attempt); - } - } - - return { - project: null, - snapshot: latestSnapshot, - }; - }, + }> => + waitForRecoverableProjectInReadModel({ + workspaceRoot, + loadSnapshot: () => api.orchestration.getSnapshot().catch(() => null), + repairSnapshot: () => api.orchestration.repairState().catch(() => null), + maxAttempts: ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS, + delayMs: ADD_PROJECT_SNAPSHOT_CATCH_UP_DELAY_MS, + }), [], ); @@ -1766,6 +1727,24 @@ export default function Sidebar() { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; if (isDuplicateProjectCreateError(description)) { + const { project, snapshot } = await waitForRecoverableProjectForDuplicateCreate({ + message: description, + workspaceRoot: cwd, + loadSnapshot: () => api.orchestration.getSnapshot().catch(() => null), + repairSnapshot: () => api.orchestration.repairState().catch(() => null), + maxAttempts: ADD_PROJECT_SNAPSHOT_CATCH_UP_MAX_ATTEMPTS, + delayMs: ADD_PROJECT_SNAPSHOT_CATCH_UP_DELAY_MS, + }); + if (snapshot) { + syncServerReadModel(snapshot); + } + if (project && snapshot) { + const recovered = await openExistingProjectFromSnapshot(project.id, snapshot); + if (recovered) { + finishAddingProject(); + return; + } + } const duplicateProjectId = extractDuplicateProjectCreateProjectId(description); const recovered = duplicateProjectId ? await recoverExistingProjectFromServer(api, ProjectId.makeUnsafe(duplicateProjectId)) @@ -1793,6 +1772,8 @@ export default function Sidebar() { recoverExistingProjectFromServer, recoverExistingProjectByWorkspaceRootFromServer, recoverProjectThreadFromServer, + syncServerReadModel, + openExistingProjectFromSnapshot, setProjectExpanded, ], ); @@ -5018,7 +4999,7 @@ export default function Sidebar() { {wordmark} diff --git a/apps/web/src/components/WorkspaceView.tsx b/apps/web/src/components/WorkspaceView.tsx index 46117eb7..cc382d8d 100644 --- a/apps/web/src/components/WorkspaceView.tsx +++ b/apps/web/src/components/WorkspaceView.tsx @@ -1,22 +1,22 @@ -// FILE: WorkspaceView.tsx -// Purpose: Render a dedicated terminal-only workspace page backed by a synthetic terminal scope. -// Layer: Workspace route surface - -import { Plus, SettingsIcon } from "~/lib/icons"; import { type TerminalCliKind } from "@t3tools/shared/terminalThreads"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { readNativeApi } from "~/nativeApi"; import { useAppSettings } from "~/appSettings"; +import { DesktopWindowControls } from "~/components/chat/DesktopWindowControls"; +import { readNativeApi } from "~/nativeApi"; import { Button } from "~/components/ui/button"; import { SidebarHeaderTrigger, SidebarInset } from "~/components/ui/sidebar"; +import { isElectron } from "~/env"; +import { Plus, SettingsIcon } from "~/lib/icons"; +import { supportsCustomDesktopTitleBar } from "~/lib/desktopWindow"; import { confirmTerminalTabClose, resolveTerminalCloseTitle, } from "~/lib/terminalCloseConfirmation"; import { resolveTerminalNewAction } from "~/lib/terminalNewAction"; import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { cn } from "~/lib/utils"; import { selectThreadTerminalState, useTerminalStateStore } from "~/terminalStateStore"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import WorkspaceSettingsSheet from "./WorkspaceSettingsSheet"; @@ -38,6 +38,7 @@ function randomTerminalId(): string { export default function WorkspaceView({ workspaceId }: { workspaceId: string }) { const { settings } = useAppSettings(); + const usesCustomDesktopTitleBar = supportsCustomDesktopTitleBar(); const workspace = useWorkspaceStore((state) => state.workspacePages.find((entry) => entry.id === workspaceId), ); @@ -390,8 +391,21 @@ export default function WorkspaceView({ workspaceId }: { workspaceId: string }) return (
-
-
+
+
{renaming ? ( @@ -415,7 +429,7 @@ export default function WorkspaceView({ workspaceId }: { workspaceId: string }) /> ) : (

setRenaming(true)} > @@ -443,6 +457,7 @@ export default function WorkspaceView({ workspaceId }: { workspaceId: string })

+
diff --git a/apps/web/src/components/chat/DesktopWindowControls.tsx b/apps/web/src/components/chat/DesktopWindowControls.tsx new file mode 100644 index 00000000..e1825130 --- /dev/null +++ b/apps/web/src/components/chat/DesktopWindowControls.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { LuCopy, LuMinus, LuSquare, LuX } from "react-icons/lu"; + +import { cn } from "~/lib/utils"; +import { + DEFAULT_DESKTOP_WINDOW_STATE, + readDesktopBridge, + supportsCustomDesktopTitleBar, +} from "~/lib/desktopWindow"; + +export function DesktopWindowControls({ className }: { className?: string }) { + const [windowState, setWindowState] = useState(DEFAULT_DESKTOP_WINDOW_STATE); + const usesCustomDesktopTitleBar = supportsCustomDesktopTitleBar(); + + useEffect(() => { + if (!usesCustomDesktopTitleBar) { + return; + } + + const bridge = readDesktopBridge(); + if (!bridge) { + return; + } + + let active = true; + void bridge.window + .getState() + .then((state) => { + if (active) { + setWindowState(state); + } + }) + .catch(() => {}); + + const unsubscribe = bridge.window.onState((state) => { + if (active) { + setWindowState(state); + } + }); + + return () => { + active = false; + unsubscribe(); + }; + }, [usesCustomDesktopTitleBar]); + + const bridge = readDesktopBridge(); + if (!usesCustomDesktopTitleBar || !bridge) { + return null; + } + + const controlButtonClassName = + "inline-flex h-full w-[46px] items-center justify-center text-muted-foreground/72 transition-colors hover:bg-accent/75 hover:text-foreground"; + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/lib/desktopProjectRecovery.test.ts b/apps/web/src/lib/desktopProjectRecovery.test.ts new file mode 100644 index 00000000..c1dfa058 --- /dev/null +++ b/apps/web/src/lib/desktopProjectRecovery.test.ts @@ -0,0 +1,113 @@ +import { ProjectId, ThreadId, type OrchestrationReadModel } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { hasLiveThreadsWithMissingProjects } from "./desktopProjectRecovery"; + +function makeProject( + overrides: Partial = {}, +): OrchestrationReadModel["projects"][number] { + return { + id: ProjectId.makeUnsafe("project-1"), + kind: "project", + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + scripts: [], + createdAt: "2026-04-20T08:00:00.000Z", + updatedAt: "2026-04-20T08:00:00.000Z", + deletedAt: null, + ...overrides, + }; +} + +function makeThread( + overrides: Partial = {}, +): OrchestrationReadModel["threads"][number] { + return { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "approval-required", + interactionMode: "chat", + envMode: "local", + branch: null, + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + parentThreadId: null, + subagentAgentId: null, + subagentNickname: null, + subagentRole: null, + forkSourceThreadId: null, + lastKnownPr: null, + latestTurn: null, + handoff: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + createdAt: "2026-04-20T08:00:00.000Z", + updatedAt: "2026-04-20T08:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + activities: [], + proposedPlans: [], + checkpoints: [], + session: null, + ...overrides, + }; +} + +function makeSnapshot( + overrides: Partial = {}, +): OrchestrationReadModel { + return { + snapshotSequence: 1, + updatedAt: "2026-04-20T08:00:00.000Z", + projects: [makeProject()], + threads: [makeThread()], + ...overrides, + }; +} + +describe("desktopProjectRecovery", () => { + it("returns false when live threads still have live project rows", () => { + const snapshot = makeSnapshot(); + + expect(hasLiveThreadsWithMissingProjects(snapshot)).toBe(false); + }); + + it("returns true when a live thread references a missing project row", () => { + const snapshot = makeSnapshot({ + projects: [], + }); + + expect(hasLiveThreadsWithMissingProjects(snapshot)).toBe(true); + }); + + it("returns true when a live thread references a deleted project row", () => { + const snapshot = makeSnapshot({ + projects: [makeProject({ deletedAt: "2026-04-20T09:00:00.000Z" })], + }); + + expect(hasLiveThreadsWithMissingProjects(snapshot)).toBe(true); + }); + + it("ignores deleted threads when deciding whether repair is needed", () => { + const snapshot = makeSnapshot({ + projects: [], + threads: [makeThread({ deletedAt: "2026-04-20T09:00:00.000Z" })], + }); + + expect(hasLiveThreadsWithMissingProjects(snapshot)).toBe(false); + }); +}); diff --git a/apps/web/src/lib/desktopProjectRecovery.ts b/apps/web/src/lib/desktopProjectRecovery.ts new file mode 100644 index 00000000..8f628d3a --- /dev/null +++ b/apps/web/src/lib/desktopProjectRecovery.ts @@ -0,0 +1,13 @@ +import type { OrchestrationReadModel } from "@t3tools/contracts"; + +export function hasLiveThreadsWithMissingProjects(snapshot: OrchestrationReadModel): boolean { + const liveProjectIds = new Set( + snapshot.projects + .filter((project) => project.deletedAt === null) + .map((project) => project.id), + ); + + return snapshot.threads.some( + (thread) => thread.deletedAt === null && !liveProjectIds.has(thread.projectId), + ); +} diff --git a/apps/web/src/lib/desktopWindow.test.ts b/apps/web/src/lib/desktopWindow.test.ts new file mode 100644 index 00000000..cc27ea08 --- /dev/null +++ b/apps/web/src/lib/desktopWindow.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { shouldUseCustomDesktopTitleBar } from "./desktopWindow"; + +describe("shouldUseCustomDesktopTitleBar", () => { + it("stays on native chrome outside Electron", () => { + expect( + shouldUseCustomDesktopTitleBar({ + isElectron: false, + platform: "Win32", + }), + ).toBe(false); + }); + + it("keeps macOS on the native traffic-light title bar", () => { + expect( + shouldUseCustomDesktopTitleBar({ + isElectron: true, + platform: "MacIntel", + }), + ).toBe(false); + }); + + it("enables custom renderer chrome on Windows and Linux", () => { + expect( + shouldUseCustomDesktopTitleBar({ + isElectron: true, + platform: "Win32", + }), + ).toBe(true); + expect( + shouldUseCustomDesktopTitleBar({ + isElectron: true, + platform: "Linux x86_64", + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/desktopWindow.ts b/apps/web/src/lib/desktopWindow.ts new file mode 100644 index 00000000..96deb05e --- /dev/null +++ b/apps/web/src/lib/desktopWindow.ts @@ -0,0 +1,30 @@ +import type { DesktopBridge, DesktopWindowState } from "@t3tools/contracts"; + +import { isElectron } from "../env"; +import { isMacPlatform } from "./utils"; + +export const DEFAULT_DESKTOP_WINDOW_STATE: DesktopWindowState = { + isMaximized: false, +}; + +export function shouldUseCustomDesktopTitleBar(input: { + isElectron: boolean; + platform: string; +}): boolean { + return input.isElectron && !isMacPlatform(input.platform); +} + +export function supportsCustomDesktopTitleBar(): boolean { + return shouldUseCustomDesktopTitleBar({ + isElectron, + platform: typeof navigator === "undefined" ? "" : navigator.platform ?? "", + }); +} + +export function readDesktopBridge(): DesktopBridge | null { + if (typeof window === "undefined") { + return null; + } + + return window.desktopBridge ?? null; +} diff --git a/apps/web/src/lib/projectCreateRecovery.test.ts b/apps/web/src/lib/projectCreateRecovery.test.ts index 48b3c5b4..32fcc673 100644 --- a/apps/web/src/lib/projectCreateRecovery.test.ts +++ b/apps/web/src/lib/projectCreateRecovery.test.ts @@ -5,8 +5,10 @@ import { describe, expect, it } from "vitest"; import { extractDuplicateProjectCreateProjectId, + findRecoverableProject, findRecoverableProjectForDuplicateCreate, isDuplicateProjectCreateError, + waitForRecoverableProjectInReadModel, waitForRecoverableProjectForDuplicateCreate, } from "./projectCreateRecovery"; @@ -86,6 +88,29 @@ describe("projectCreateRecovery", () => { expect(recovered?.id).toBe("project-123"); }); + it("finds a recoverable project by exact id before falling back to workspace root", () => { + const recovered = findRecoverableProject({ + projectId: "project-123", + workspaceRoot: "/Users/tester/Code/one", + projects: [ + { + id: "project-123", + kind: "project", + workspaceRoot: "/Users/tester/Code/two", + deletedAt: null, + }, + { + id: "project-456", + kind: "project", + workspaceRoot: "/Users/tester/Code/one", + deletedAt: null, + }, + ], + }); + + expect(recovered?.id).toBe("project-123"); + }); + it("ignores deleted and non-project rows during recovery", () => { const recovered = findRecoverableProjectForDuplicateCreate({ message: @@ -143,4 +168,77 @@ describe("projectCreateRecovery", () => { expect(result.project?.id).toBe("project-123"); expect(result.snapshot?.projects).toHaveLength(1); }); + + it("repairs the snapshot after polling when a directly created project is still missing", async () => { + let repairCalls = 0; + + const result = await waitForRecoverableProjectInReadModel({ + projectId: "project-123", + workspaceRoot: "/Users/tester/Code/one", + loadSnapshot: async () => ({ + snapshotSequence: 1, + updatedAt: "2026-04-21T00:00:00.000Z", + projects: [], + threads: [], + }), + repairSnapshot: async () => { + repairCalls += 1; + return { + snapshotSequence: 2, + updatedAt: "2026-04-21T00:00:01.000Z", + projects: [ + { + id: "project-123", + kind: "project", + title: "One", + workspaceRoot: "/Users/tester/Code/one", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-04-21T00:00:00.000Z", + updatedAt: "2026-04-21T00:00:01.000Z", + deletedAt: null, + }, + ], + threads: [], + }; + }, + maxAttempts: 2, + delayMs: 0, + }); + + expect(repairCalls).toBe(1); + expect(result.project?.id).toBe("project-123"); + expect(result.snapshot?.projects).toHaveLength(1); + }); + + it("repairs duplicate-create recovery when the fresh snapshot still has no project rows", async () => { + let repairCalls = 0; + + const result = await waitForRecoverableProjectForDuplicateCreate({ + message: + "Orchestration command invariant failed (project.create): Project 'project-123' already uses workspace root '/Users/tester/Code/one'.", + workspaceRoot: "/Users/tester/Code/one", + loadSnapshot: async () => ({ + projects: [], + }), + repairSnapshot: async () => { + repairCalls += 1; + return { + projects: [ + { + id: "project-123", + workspaceRoot: "/Users/tester/Code/one", + deletedAt: null, + }, + ], + }; + }, + maxAttempts: 2, + delayMs: 0, + }); + + expect(repairCalls).toBe(1); + expect(result.project?.id).toBe("project-123"); + expect(result.snapshot?.projects).toHaveLength(1); + }); }); diff --git a/apps/web/src/lib/projectCreateRecovery.ts b/apps/web/src/lib/projectCreateRecovery.ts index 36f0de09..314bb483 100644 --- a/apps/web/src/lib/projectCreateRecovery.ts +++ b/apps/web/src/lib/projectCreateRecovery.ts @@ -2,6 +2,7 @@ // Purpose: Centralizes duplicate `project.create` error parsing and recovery helpers. // Exports: duplicate-create error guards plus snapshot matching for import recovery. +import type { OrchestrationReadModel } from "@t3tools/contracts"; import { workspaceRootsEqual } from "@t3tools/shared/threadWorkspace"; const DUPLICATE_PROJECT_CREATE_ERROR_PREFIX = @@ -20,6 +21,11 @@ interface SnapshotWithProjects(input: { - readonly message: string; - readonly projects: readonly T[]; - readonly workspaceRoot: string; -}): T | null { - if (!isDuplicateProjectCreateError(input.message)) { - return null; - } - - const duplicateProjectId = extractDuplicateProjectCreateProjectId(input.message); - if (duplicateProjectId) { +export function findRecoverableProject( + input: ProjectLookupInput & { + readonly projects: readonly T[]; + }, +): T | null { + if (input.projectId) { const projectById = input.projects.find( (project) => project.deletedAt === null && isRecoverableProjectKind(project.kind) && - project.id === duplicateProjectId, + project.id === input.projectId, ); if (projectById) { return projectById; } } + if (!input.workspaceRoot) { + return null; + } + return ( input.projects.find( (project) => @@ -84,6 +86,81 @@ export function findRecoverableProjectForDuplicateCreate< ); } +// Prefers the explicit duplicate id, then falls back to workspace-root matching for older clients. +export function findRecoverableProjectForDuplicateCreate< + T extends DuplicateProjectCreateRecoveryCandidate, +>(input: { + readonly message: string; + readonly projects: readonly T[]; + readonly workspaceRoot: string; +}): T | null { + if (!isDuplicateProjectCreateError(input.message)) { + return null; + } + + return findRecoverableProject({ + projects: input.projects, + projectId: extractDuplicateProjectCreateProjectId(input.message), + workspaceRoot: input.workspaceRoot, + }); +} + +export async function waitForRecoverableProjectInReadModel(input: ProjectLookupInput & { + readonly loadSnapshot: () => Promise; + readonly repairSnapshot?: (() => Promise) | undefined; + readonly maxAttempts?: number; + readonly delayMs?: number; +}): Promise<{ + project: OrchestrationReadModel["projects"][number] | null; + snapshot: OrchestrationReadModel | null; +}> { + let latestSnapshot: OrchestrationReadModel | null = null; + const maxAttempts = input.maxAttempts ?? DEFAULT_RECOVERY_MAX_ATTEMPTS; + const delayMs = input.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const snapshot = await input.loadSnapshot(); + if (snapshot) { + latestSnapshot = snapshot; + const project = findRecoverableProject({ + projects: snapshot.projects, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }); + if (project) { + return { project, snapshot }; + } + } + + if (attempt < maxAttempts) { + await wait(delayMs * attempt); + } + } + + if (input.repairSnapshot) { + const repairedSnapshot = await input.repairSnapshot(); + if (repairedSnapshot) { + latestSnapshot = repairedSnapshot; + const repairedProject = findRecoverableProject({ + projects: repairedSnapshot.projects, + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }); + if (repairedProject) { + return { + project: repairedProject, + snapshot: repairedSnapshot, + }; + } + } + } + + return { + project: null, + snapshot: latestSnapshot, + }; +} + // Retries snapshot reads briefly so freshly restored projects can be reused by the first-send flow. export async function waitForRecoverableProjectForDuplicateCreate< TSnapshot extends SnapshotWithProjects, @@ -91,6 +168,7 @@ export async function waitForRecoverableProjectForDuplicateCreate< readonly message: string; readonly workspaceRoot: string; readonly loadSnapshot: () => Promise; + readonly repairSnapshot?: (() => Promise) | undefined; readonly maxAttempts?: number; readonly delayMs?: number; }): Promise<{ @@ -120,6 +198,24 @@ export async function waitForRecoverableProjectForDuplicateCreate< } } + if (input.repairSnapshot) { + const repairedSnapshot = await input.repairSnapshot(); + if (repairedSnapshot) { + latestSnapshot = repairedSnapshot; + const repairedProject = findRecoverableProjectForDuplicateCreate({ + message: input.message, + projects: repairedSnapshot.projects, + workspaceRoot: input.workspaceRoot, + }) as TSnapshot["projects"][number] | null; + if (repairedProject) { + return { + project: repairedProject, + snapshot: repairedSnapshot, + }; + } + } + } + return { project: null, snapshot: latestSnapshot, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index e974f4ed..f9c8a145 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -49,6 +49,7 @@ import { useWorkspaceStore, workspaceThreadId } from "../workspaceStore"; import { useRetainedThreadDetailIds } from "../threadDetailSubscriptionRetention"; import { useAppTypography } from "../hooks/useAppTypography"; import { invalidateGitQueries } from "../lib/gitReactQuery"; +import { hasLiveThreadsWithMissingProjects } from "../lib/desktopProjectRecovery"; import { parseDiffRouteSearch } from "../diffRouteSearch"; import { resolveSplitViewThreadIds, selectSplitView, useSplitViewStore } from "../splitViewStore"; @@ -769,12 +770,15 @@ function DesktopProjectBootstrap() { attemptedRecoveryRef.current = true; // Shell subscriptions should normally hydrate the sidebar. If the desktop - // UI comes up empty against a non-empty local database, force one read-model - // refresh and fall back to repair only when the snapshot is still empty. + // UI comes up empty, or threads resolve before their project rows do, force + // one read-model refresh and fall back to repair before accepting the state. void api.orchestration .getSnapshot() .then((snapshot) => { - if (snapshot.projects.length > 0 || snapshot.threads.length > 0) { + const needsRepair = + (snapshot.projects.length === 0 && snapshot.threads.length === 0) || + hasLiveThreadsWithMissingProjects(snapshot); + if (!needsRepair) { syncServerReadModel(snapshot); return snapshot; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 169ae78d..3333dde6 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -25,6 +25,7 @@ import { useAppSettings, } from "../appSettings"; import { APP_VERSION } from "../branding"; +import { DesktopWindowControls } from "../components/chat/DesktopWindowControls"; import { ClaudeAI, Gemini, OpenAI } from "../components/Icons"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; @@ -43,6 +44,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip" import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; +import { supportsCustomDesktopTitleBar } from "../lib/desktopWindow"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { ArchiveIcon, @@ -2117,8 +2119,11 @@ function SettingsRouteView() { {isElectron ? (
@@ -2136,6 +2141,7 @@ function SettingsRouteView() { Restore defaults
+
) : (
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 0a81be47..acef168c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -216,6 +216,10 @@ export interface DesktopNotificationInput { threadId?: ThreadId; } +export interface DesktopWindowState { + isMaximized: boolean; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -236,6 +240,13 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + window: { + minimize: () => Promise; + toggleMaximize: () => Promise; + close: () => Promise; + getState: () => Promise; + onState: (listener: (state: DesktopWindowState) => void) => () => void; + }; notifications: { isSupported: () => Promise; show: (input: DesktopNotificationInput) => Promise;