diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 2c5db8a16d8..5a47a8ca8b8 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -4,6 +4,7 @@ import type { DesktopRuntimeArch, DesktopRuntimeInfo, } from "@t3tools/contracts"; +import * as NodePath from "@effect/platform-node/NodePath"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -246,4 +247,6 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* ( }); export const layer = (input: MakeDesktopEnvironmentInput) => - Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)); + Layer.effect(DesktopEnvironment, makeDesktopEnvironment(input)).pipe( + Layer.provide(input.platform === "win32" ? NodePath.layerWin32 : NodePath.layerPosix), + ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..75fb1a20966 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,6 +15,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + contextMenuStyle: "default", dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 76d5d34c355..bb0256e2983 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -4,6 +4,7 @@ import { Link } from "@tanstack/react-router"; import { useCallback, useMemo, useRef, useState } from "react"; import { defaultInstanceIdForDriver, + type ContextMenuStyle, type DesktopUpdateChannel, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, @@ -101,6 +102,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const CONTEXT_MENU_STYLE_LABELS = { + default: "Default", + native: "Native", + custom: "Custom", +} as const satisfies Record; + const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex"); function withoutProviderInstanceKey( @@ -395,6 +402,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.contextMenuStyle !== DEFAULT_UNIFIED_SETTINGS.contextMenuStyle + ? ["Context menu style"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -434,6 +444,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.contextMenuStyle, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, @@ -458,6 +469,7 @@ export function useSettingsRestore(onRestored?: () => void) { setTheme("system"); updateSettings({ timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + contextMenuStyle: DEFAULT_UNIFIED_SETTINGS.contextMenuStyle, diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, @@ -594,6 +606,48 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + contextMenuStyle: DEFAULT_UNIFIED_SETTINGS.contextMenuStyle, + }) + } + /> + ) : null + } + control={ + + } + /> + { }); }); - it("forwards context menu metadata to the desktop bridge", async () => { + it("forwards context menu metadata to the desktop bridge for native menus", async () => { const showContextMenu = vi.fn().mockResolvedValue("delete"); - getWindowForTest().desktopBridge = makeDesktopBridge({ showContextMenu }); + getWindowForTest().desktopBridge = makeDesktopBridge({ + getClientSettings: async () => ({ + ...DEFAULT_CLIENT_SETTINGS, + contextMenuStyle: "native", + }), + showContextMenu, + }); const { createLocalApi } = await import("./localApi"); const api = createLocalApi(rpcClientMock as never); @@ -632,6 +639,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + contextMenuStyle: "default" as const, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, @@ -695,6 +703,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + contextMenuStyle: "default" as const, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..81961641670 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,4 +1,9 @@ -import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; +import { + DEFAULT_CLIENT_SETTINGS, + type ContextMenuItem, + type ContextMenuStyle, + type LocalApi, +} from "@t3tools/contracts"; import type { WsRpcClient } from "@t3tools/client-runtime"; import { resetVcsStatusStateForTests } from "./lib/vcsStatusState"; @@ -25,6 +30,7 @@ import { writeBrowserSavedEnvironmentRegistry, writeBrowserSavedEnvironmentSecret, } from "./clientPersistenceStorage"; +import { isMacPlatform } from "./lib/utils"; let cachedApi: LocalApi | undefined; @@ -32,6 +38,28 @@ function unavailableLocalBackendError(): Error { return new Error("Local backend API is unavailable before a backend is paired."); } +async function readContextMenuStyle(): Promise { + try { + const settings = window.desktopBridge + ? await window.desktopBridge.getClientSettings() + : readBrowserClientSettings(); + return settings?.contextMenuStyle ?? DEFAULT_CLIENT_SETTINGS.contextMenuStyle; + } catch { + return DEFAULT_CLIENT_SETTINGS.contextMenuStyle; + } +} + +function shouldUseNativeContextMenu(style: ContextMenuStyle): boolean { + if (style === "custom") { + return false; + } + if (style === "native") { + return true; + } + const platform = typeof navigator === "undefined" ? "" : navigator.platform; + return Boolean(window.desktopBridge) && isMacPlatform(platform); +} + function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { return { dialogs: { @@ -68,8 +96,13 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { items: readonly ContextMenuItem[], position?: { x: number; y: number }, ): Promise => { - if (window.desktopBridge) { - return window.desktopBridge.showContextMenu(items, position) as Promise; + const style = await readContextMenuStyle(); + if (shouldUseNativeContextMenu(style) && window.desktopBridge) { + try { + return (await window.desktopBridge.showContextMenu(items, position)) as T | null; + } catch { + return null; + } } return showContextMenuFallback(items, position); }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..e8db543def1 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,10 @@ export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]) export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; +export const ContextMenuStyle = Schema.Literals(["default", "native", "custom"]); +export type ContextMenuStyle = typeof ContextMenuStyle.Type; +export const DEFAULT_CONTEXT_MENU_STYLE: ContextMenuStyle = "default"; + export const SidebarProjectSortOrder = Schema.Literals(["updated_at", "created_at", "manual"]); export type SidebarProjectSortOrder = typeof SidebarProjectSortOrder.Type; export const DEFAULT_SIDEBAR_PROJECT_SORT_ORDER: SidebarProjectSortOrder = "updated_at"; @@ -43,6 +47,9 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + contextMenuStyle: ContextMenuStyle.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_CONTEXT_MENU_STYLE)), + ), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -478,6 +485,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + contextMenuStyle: Schema.optionalKey(ContextMenuStyle), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey(