From c01b6cbc9a091079424108723188eb586b0d81eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 10:59:47 +0000 Subject: [PATCH] fix(web): Restore device defaults resets environment-wide settings while a project is picked Restore read and wrote through the current settings scope. When a project was picked (the choice carries over to General through the URL), the project-scope plan dropped every server write because the patch held settings a project cannot override, while device-only settings still saved, so environment-wide resets such as Auto-settle were silently lost. The Restore button now runs in its own scope that keeps only the chosen environment, so it reads and resets environment-wide values. AI-assisted; verified with the tests and tools listed above. --- .../components/settings/SettingsPanels.tsx | 38 ++++- .../settings/SettingsRestore.browser.test.tsx | 141 ++++++++++++++++++ apps/web/src/routes/_chat.settings.tsx | 19 +-- 3 files changed, 177 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/components/settings/SettingsRestore.browser.test.tsx diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 95acb62765c9..2e3410f82c39 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,7 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { NotificationSettings } from "./NotificationSettings"; import { PRIVACY_POLICY_URL } from "../../legalLinks"; -import { CheckIcon } from "lucide-react"; +import { CheckIcon, RotateCcwIcon } from "lucide-react"; import { msg, T, useGT, useMessages } from "gt-react"; import { Link, useNavigate } from "@tanstack/react-router"; import { useRef, useState } from "react"; @@ -59,7 +59,11 @@ import { useUpdateScopedSettings, } from "./useScopedSettings"; import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; -import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; +import { + SettingsScopeProvider, + useOptionalSettingsScope, + useSettingsScope, +} from "./SettingsScopeContext"; import { scopedIdleCompaction } from "./scopedSettings"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettings"; @@ -427,7 +431,7 @@ function AboutVersionSection() { ); } -export function useSettingsRestore(onRestored?: () => void) { +function useSettingsRestore(onRestored?: () => void) { const { theme, setTheme, @@ -717,6 +721,34 @@ export function useSettingsRestore(onRestored?: () => void) { }; } +export function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) { + const { search, selectScope } = useSettingsScope(); + // Restore resets environment-wide settings, which a project or checkout scope cannot write. + return ( + + + + ); +} + +function RestoreDeviceDefaultsAction({ onRestored }: { onRestored: () => void }) { + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); + return ( + + ); +} + const AUTO_SETTLE_DEFAULT_DAYS = DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ?? 7; function AutoSettleDaysInput({ diff --git a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx new file mode 100644 index 000000000000..e723e3555f5b --- /dev/null +++ b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx @@ -0,0 +1,141 @@ +import { RegistryContext } from "@effect/atom-react"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ProjectId, + type ServerSettings, +} from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { AsyncResult } from "effect/reactivity"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; + +import { render } from "../../../test/render"; +import type { Project } from "../../types"; + +const state = vi.hoisted(() => ({ + projects: [] as Project[], + environments: [] as unknown[], + updateSettings: + vi.fn< + (request: { + environmentId: unknown; + input: { patch: Record }; + }) => Promise + >(), + confirm: vi.fn<() => Promise>(), +})); + +vi.mock(import("~/state/environments"), async (original) => ({ + ...(await original()), + useEnvironments: (() => ({ environments: state.environments })) as never, + usePrimaryEnvironmentId: () => primaryId, +})); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useProjects: (() => state.projects) as never, +})); +vi.mock(import("~/state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.updateSettings) as never, +})); +vi.mock(import("~/state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, +})); +vi.mock(import("~/localApi"), async (original) => ({ + ...(await original()), + readLocalApi: (() => ({ dialogs: { confirm: state.confirm } })) as never, +})); + +import { writeBrowserClientSettings } from "../../clientPersistenceStorage"; +import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { RestoreDeviceDefaultsButton } from "./SettingsPanels"; +import { SettingsScopeProvider } from "./SettingsScopeContext"; + +const primaryId = EnvironmentId.make("primary"); +const remoteId = EnvironmentId.make("remote"); +const customized = { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: 30, + enableProviderUpdateChecks: !DEFAULT_SERVER_SETTINGS.enableProviderUpdateChecks, +}; + +function setup(primarySettings: ServerSettings) { + vi.clearAllMocks(); + // Browser test files share localStorage, and an earlier file may leave client settings changed. + writeBrowserClientSettings(DEFAULT_CLIENT_SETTINGS); + state.environments = ( + [ + [primaryId, primarySettings], + [remoteId, customized], + ] as const + ).map(([environmentId, settings]) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings, + environment: { platform: { machine: "server" }, capabilities: { projectOrganization: true } }, + }, + })); + state.projects = [primaryId, remoteId].map((environmentId) => ({ + id: ProjectId.make(`project-${environmentId}`), + environmentId, + title: "Shared repo", + workspaceRoot: `/work/${environmentId}`, + repositoryIdentity: { + canonicalKey: "github.com/example/shared-repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://github.com/example/shared-repo.git", + }, + }, + defaultModelSelection: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + scripts: [], + })); + state.updateSettings.mockResolvedValue(AsyncResult.success(customized)); + state.confirm.mockResolvedValue(true); + return buildSidebarProjectSnapshots({ + projects: state.projects, + settings: DEFAULT_CLIENT_SETTINGS, + primaryEnvironmentId: primaryId, + resolveEnvironmentLabel: (id) => id, + })[0]!.projectKey; +} + +describe("RestoreDeviceDefaultsButton", () => { + it("restores environment-wide defaults while a project is picked", async () => { + const projectKey = setup(customized); + const onRestored = vi.fn(); + await using screen = await render( + + {}}> + + + , + ); + + const button = screen.getByRole("button", { name: "Restore device defaults" }); + await expect.element(button).toBeEnabled(); + await userEvent.click(button); + await vi.waitFor(() => expect(state.updateSettings).toHaveBeenCalledTimes(2)); + + expect( + state.updateSettings.mock.calls.map(([request]) => [ + request.environmentId, + request.input.patch.sidebarAutoSettleAfterDays, + request.input.patch.projectSettingsOverrides, + ]), + ).toEqual([ + [primaryId, DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays, undefined], + [remoteId, DEFAULT_SERVER_SETTINGS.sidebarAutoSettleAfterDays, undefined], + ]); + }); +}); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7758c9ca5591..de4a7f86445f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,9 +1,7 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { Outlet, createFileRoute, redirect, useLocation } from "@tanstack/react-router"; import { useState, type ReactNode } from "react"; -import { RotateCcwIcon } from "lucide-react"; -import { Button } from "../components/ui/button"; -import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { RestoreDeviceDefaultsButton } from "../components/settings/SettingsPanels"; import { AppRouteModal, AppRouteModalClose } from "../components/AppRouteModal"; import { SettingsBreadcrumb } from "../components/settings/SettingsBreadcrumb"; @@ -27,21 +25,6 @@ import { movedSettingsPath, } from "../components/settings/settingsSearch"; -function RestoreDeviceDefaultsButton({ onRestored }: { onRestored: () => void }) { - const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); - return ( - - ); -} - function SettingsScopeBoundary({ pathname, children }: { pathname: string; children: ReactNode }) { const { scope, connectedEnvironments } = useSettingsScope(); const { environments } = useEnvironments();