From 4b3c156f6e548a7ff269ab49026080bdbbd638af Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Tue, 2 Jun 2026 07:00:03 +0300 Subject: [PATCH 1/3] add configurable context menu style --- .../settings/DesktopClientSettings.test.ts | 1 + .../components/settings/SettingsPanels.tsx | 54 +++++++++++++++++++ apps/web/src/localApi.test.ts | 2 + apps/web/src/localApi.ts | 37 +++++++++++-- packages/contracts/src/settings.ts | 8 +++ 5 files changed, 99 insertions(+), 3 deletions(-) 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..4c92e2c2c65 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={ + + } + /> + { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + contextMenuStyle: "default" as const, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, @@ -695,6 +696,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..39750a0bd99 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"; @@ -32,6 +37,27 @@ 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; + } + return Boolean(window.desktopBridge); +} + function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { return { dialogs: { @@ -68,8 +94,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( From 6f7c107e2101ba7ab1aa4b5055899c2e58b68519 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Tue, 2 Jun 2026 07:36:20 +0300 Subject: [PATCH 2/3] adjust default context menu style --- apps/web/src/components/settings/SettingsPanels.tsx | 2 +- apps/web/src/localApi.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4c92e2c2c65..bb0256e2983 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -608,7 +608,7 @@ export function GeneralSettingsPanel() { Date: Tue, 2 Jun 2026 08:14:41 +0300 Subject: [PATCH 3/3] Fix context menu test failures --- apps/desktop/src/app/DesktopEnvironment.ts | 5 ++++- apps/web/src/localApi.test.ts | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) 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/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 2a55cf2fa63..fb247e2a84c 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -1,5 +1,6 @@ import { CommandId, + DEFAULT_CLIENT_SETTINGS, DEFAULT_SERVER_SETTINGS, type DesktopBridge, EnvironmentId, @@ -591,9 +592,15 @@ describe("wsApi", () => { }); }); - 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);