From 437eb4ff17c9598ba75f936f9d2bd381246f578b Mon Sep 17 00:00:00 2001 From: chengww Date: Sun, 5 Apr 2026 17:06:41 +0800 Subject: [PATCH 1/3] fix(claude-plugin): sync current provider config to settings.json on toggle enable - Extract syncClaudePluginIfChanged to share logic between autoSaveSettings and saveSettings - Fix P1: enableClaudePluginIntegration toggle in General tab now actually syncs ~/.claude/settings.json - Fix P2: check syncCurrentProvidersLiveSafe() return value and show toast on failure - Fix P3: sync providers on both enable and disable, not just enable - Fix P4: avoid double syncCurrentProvidersLiveSafe when plugin toggle + dir change happen together - Remove duplicate comment - Add missing providersApi.getCurrent/getAll mocks in tests --- src/hooks/useSettings.ts | 95 ++++++++++++++++++++++---------- tests/hooks/useSettings.test.tsx | 11 +++- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 121f25c72e..04d890db7f 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -122,6 +122,57 @@ export function useSettings(): UseSettingsResult { setRequiresRestart, ]); + // 同步 Claude 插件集成配置到 ~/.claude/settings.json + // 返回 true 表示已执行过 syncCurrentProvidersLiveSafe,调用方可跳过重复同步 + const syncClaudePluginIfChanged = useCallback( + async (enabled: boolean | undefined): Promise => { + if ( + enabled === undefined || + enabled === data?.enableClaudePluginIntegration + ) + return false; + try { + if (enabled) { + const currentId = await providersApi.getCurrent("claude"); + let isOfficial = false; + if (currentId) { + const allProviders = await providersApi.getAll("claude"); + isOfficial = allProviders[currentId]?.category === "official"; + } + await settingsApi.applyClaudePluginConfig({ official: isOfficial }); + } else { + await settingsApi.applyClaudePluginConfig({ official: true }); + } + + const syncResult = await syncCurrentProvidersLiveSafe(); + if (!syncResult.ok) { + console.warn( + "[useSettings] Failed to sync providers after toggling Claude plugin", + syncResult.error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + } + return true; + } catch (error) { + console.warn( + "[useSettings] Failed to sync Claude plugin config", + error, + ); + toast.error( + t("notifications.syncClaudePluginFailed", { + defaultValue: "同步 Claude 插件失败", + }), + ); + return false; + } + }, + [data?.enableClaudePluginIntegration, t], + ); + // 即时保存设置(用于 General 标签页的实时更新) // 保存基础配置 + 独立的系统 API 调用(开机自启) const autoSaveSettings = useCallback( @@ -202,6 +253,8 @@ export function useSettings(): UseSettingsResult { } } + await syncClaudePluginIfChanged(payload.enableClaudePluginIntegration); + // 持久化语言偏好 try { if (typeof window !== "undefined" && updates.language) { @@ -233,7 +286,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, t], + [data, saveMutation, settings, syncClaudePluginIfChanged, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) @@ -323,30 +376,9 @@ export function useSettings(): UseSettingsResult { } } - // 只在 Claude 插件集成状态真正改变时调用系统 API - if ( - payload.enableClaudePluginIntegration !== undefined && - payload.enableClaudePluginIntegration !== - data?.enableClaudePluginIntegration - ) { - try { - if (payload.enableClaudePluginIntegration) { - await settingsApi.applyClaudePluginConfig({ official: false }); - } else { - await settingsApi.applyClaudePluginConfig({ official: true }); - } - } catch (error) { - console.warn( - "[useSettings] Failed to sync Claude plugin config", - error, - ); - toast.error( - t("notifications.syncClaudePluginFailed", { - defaultValue: "同步 Claude 插件失败", - }), - ); - } - } + const pluginSynced = await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + ); try { if (typeof window !== "undefined") { @@ -369,6 +401,7 @@ export function useSettings(): UseSettingsResult { } // 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果插件同步已经执行过 syncCurrentProvidersLiveSafe,则跳过避免重复 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; @@ -376,11 +409,12 @@ export function useSettings(): UseSettingsResult { const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir; if ( - claudeDirChanged || - codexDirChanged || - geminiDirChanged || - opencodeDirChanged || - openclawDirChanged + !pluginSynced && + (claudeDirChanged || + codexDirChanged || + geminiDirChanged || + opencodeDirChanged || + openclawDirChanged) ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { @@ -422,6 +456,7 @@ export function useSettings(): UseSettingsResult { saveMutation, settings, setRequiresRestart, + syncClaudePluginIfChanged, t, ], ); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 98f11176aa..08d48b5e34 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -11,6 +11,8 @@ const applyClaudeOnboardingSkipMock = vi.fn(); const clearClaudeOnboardingSkipMock = vi.fn(); const syncCurrentProvidersLiveMock = vi.fn(); const updateTrayMenuMock = vi.fn(); +const getCurrentMock = vi.fn(); +const getAllMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -61,6 +63,8 @@ vi.mock("@/lib/api", () => ({ }, providersApi: { updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args), + getCurrent: (...args: unknown[]) => getCurrentMock(...args), + getAll: (...args: unknown[]) => getAllMock(...args), }, })); @@ -127,6 +131,8 @@ describe("useSettings hook", () => { applyClaudeOnboardingSkipMock.mockReset(); clearClaudeOnboardingSkipMock.mockReset(); syncCurrentProvidersLiveMock.mockReset(); + getCurrentMock.mockReset(); + getAllMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -163,6 +169,9 @@ describe("useSettings hook", () => { applyClaudePluginConfigMock.mockResolvedValue(true); applyClaudeOnboardingSkipMock.mockResolvedValue(true); clearClaudeOnboardingSkipMock.mockResolvedValue(true); + syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true }); + getCurrentMock.mockResolvedValue(null); + getAllMock.mockResolvedValue({}); }); it("auto-saves and applies Claude onboarding skip when toggled on", async () => { @@ -276,7 +285,7 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); expect(window.localStorage.getItem("language")).toBe("en"); expect(toastErrorMock).not.toHaveBeenCalled(); - // 目录有变化,应触发一次同步当前供应商到 live + // 插件同步已包含 syncCurrentProvidersLiveSafe,目录变更不再重复调用 expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1); }); From f0873634e675ec3dda67899887bbe1613bc6a503 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 21 Apr 2026 11:09:56 +0800 Subject: [PATCH 2/3] style: reformat after rebase onto main Prettier flagged a line-break introduced by the openclaw directory change (from main) after rebase. --- src/hooks/useSettings.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 04d890db7f..4212566aa3 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -406,8 +406,7 @@ export function useSettings(): UseSettingsResult { const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; - const openclawDirChanged = - sanitizedOpenclawDir !== previousOpenclawDir; + const openclawDirChanged = sanitizedOpenclawDir !== previousOpenclawDir; if ( !pluginSynced && (claudeDirChanged || From 65d7038b84802b5ab1247c98082edc08f3a5e473 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 21 Apr 2026 11:32:33 +0800 Subject: [PATCH 3/3] fix(claude-plugin): read prev enabled state from live cache to avoid stale closure syncClaudePluginIfChanged compared enabled against data?.enableClaudePluginIntegration captured in a useCallback closure. After invalidateQueries + refetch, the React Query cache is up to date, but the consuming hook's closure does not see the new value until React re-renders. Quick on->off toggles could therefore skip applyClaudePluginConfig, leaving ~/.claude/config.json in the previously enabled state even though settings.json was persisted as disabled. Read the previous value synchronously from queryClient.getQueryData(["settings"]) before saveMutation.mutateAsync(), then pass it to the helper as prevEnabled. getQueryData bypasses the closure and reflects the live cache at call time. Test covers the race: closure data stays at false while the cache reports true; the helper must still call applyClaudePluginConfig({ official: true }). --- src/hooks/useSettings.ts | 38 ++++++++++++++++------ tests/hooks/useSettings.test.tsx | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 4212566aa3..c7c77af969 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,6 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; import { providersApi, settingsApi, type AppId } from "@/lib/api"; import { syncCurrentProvidersLiveSafe } from "@/utils/postChangeSync"; import { useSettingsQuery, useSaveSettingsMutation } from "@/lib/query"; @@ -63,6 +64,7 @@ export function useSettings(): UseSettingsResult { const { t } = useTranslation(); const { data } = useSettingsQuery(); const saveMutation = useSaveSettingsMutation(); + const queryClient = useQueryClient(); // 1️⃣ 表单状态管理 const { @@ -124,13 +126,14 @@ export function useSettings(): UseSettingsResult { // 同步 Claude 插件集成配置到 ~/.claude/settings.json // 返回 true 表示已执行过 syncCurrentProvidersLiveSafe,调用方可跳过重复同步 + // prevEnabled 必须由调用方在 saveMutation 之前从实时缓存(queryClient.getQueryData)捕获, + // 避免 useCallback closure 中 data 因未 re-render 而滞后导致的快速连切 race。 const syncClaudePluginIfChanged = useCallback( - async (enabled: boolean | undefined): Promise => { - if ( - enabled === undefined || - enabled === data?.enableClaudePluginIntegration - ) - return false; + async ( + enabled: boolean | undefined, + prevEnabled: boolean | undefined, + ): Promise => { + if (enabled === undefined || enabled === prevEnabled) return false; try { if (enabled) { const currentId = await providersApi.getCurrent("claude"); @@ -170,7 +173,7 @@ export function useSettings(): UseSettingsResult { return false; } }, - [data?.enableClaudePluginIntegration, t], + [t], ); // 即时保存设置(用于 General 标签页的实时更新) @@ -203,6 +206,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + // 保存到配置文件 await saveMutation.mutateAsync(payload); @@ -253,7 +262,10 @@ export function useSettings(): UseSettingsResult { } } - await syncClaudePluginIfChanged(payload.enableClaudePluginIntegration); + await syncClaudePluginIfChanged( + payload.enableClaudePluginIntegration, + prevPluginEnabled, + ); // 持久化语言偏好 try { @@ -286,7 +298,7 @@ export function useSettings(): UseSettingsResult { throw error; } }, - [data, saveMutation, settings, syncClaudePluginIfChanged, t], + [data, queryClient, saveMutation, settings, syncClaudePluginIfChanged, t], ); // 完整保存设置(用于 Advanced 标签页的手动保存) @@ -328,6 +340,12 @@ export function useSettings(): UseSettingsResult { language: mergedSettings.language, }; + // 在 mutate 之前从实时缓存捕获上一次持久化的插件集成状态, + // 避免 closure 里的 data 因 React 尚未 re-render 而滞后 + const prevPluginEnabled = queryClient.getQueryData([ + "settings", + ])?.enableClaudePluginIntegration; + await saveMutation.mutateAsync(payload); await settingsApi.setAppConfigDirOverride(sanitizedAppDir ?? null); @@ -378,6 +396,7 @@ export function useSettings(): UseSettingsResult { const pluginSynced = await syncClaudePluginIfChanged( payload.enableClaudePluginIntegration, + prevPluginEnabled, ); try { @@ -452,6 +471,7 @@ export function useSettings(): UseSettingsResult { appConfigDir, data, initialAppConfigDir, + queryClient, saveMutation, settings, setRequiresRestart, diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index 08d48b5e34..a4c011e9de 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -13,6 +13,7 @@ const syncCurrentProvidersLiveMock = vi.fn(); const updateTrayMenuMock = vi.fn(); const getCurrentMock = vi.fn(); const getAllMock = vi.fn(); +const getQueryDataMock = vi.fn(); const toastErrorMock = vi.fn(); const toastSuccessMock = vi.fn(); @@ -48,6 +49,18 @@ vi.mock("@/lib/query", () => ({ }), })); +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual( + "@tanstack/react-query", + ); + return { + ...actual, + useQueryClient: () => ({ + getQueryData: (...args: unknown[]) => getQueryDataMock(...args), + }), + }; +}); + vi.mock("@/lib/api", () => ({ settingsApi: { setAppConfigDirOverride: (...args: unknown[]) => @@ -133,6 +146,7 @@ describe("useSettings hook", () => { syncCurrentProvidersLiveMock.mockReset(); getCurrentMock.mockReset(); getAllMock.mockReset(); + getQueryDataMock.mockReset(); toastErrorMock.mockReset(); toastSuccessMock.mockReset(); window.localStorage.clear(); @@ -172,6 +186,8 @@ describe("useSettings hook", () => { syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true }); getCurrentMock.mockResolvedValue(null); getAllMock.mockResolvedValue({}); + // 默认将 queryClient 缓存对齐到 serverSettings,既有断言的 "prev === data" 语义保持不变 + getQueryDataMock.mockImplementation(() => serverSettings); }); it("auto-saves and applies Claude onboarding skip when toggled on", async () => { @@ -369,6 +385,45 @@ describe("useSettings hook", () => { expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true); }); + it("detects plugin toggle via live cache even when closure data is stale", async () => { + // 模拟快速连切后的 race:useSettingsQueryMock 的 data 滞后停留在 false(closure 未更新), + // 但 queryClient 缓存(getQueryData)实时值已为 true(上次持久化到 enabled), + // form 里用户想切回 false。旧实现会因 data === form 而跳过副作用;新实现应读 prev=true 并执行。 + serverSettings = { + ...serverSettings, + enableClaudePluginIntegration: false, + }; + useSettingsQueryMock.mockReturnValue({ + data: serverSettings, + isLoading: false, + }); + + settingsFormMock = createSettingsFormMock({ + settings: { + ...serverSettings, + enableClaudePluginIntegration: false, + language: "zh", + }, + }); + directorySettingsMock = createDirectorySettingsMock(); + + // 缓存里的"真实上次值"是 true(enabled),与 closure data(false) 有时序差 + getQueryDataMock.mockImplementation(() => ({ + ...serverSettings, + enableClaudePluginIntegration: true, + })); + + const { result } = renderHook(() => useSettings()); + + await act(async () => { + await result.current.saveSettings(undefined, { silent: true }); + }); + + // 修复生效:读的是缓存实时值 true,payload=false,差异触发 clear_claude_config + expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true }); + expect(syncCurrentProvidersLiveMock).toHaveBeenCalled(); + }); + it("resets form, language and directories using server data", () => { serverSettings = { ...serverSettings,