From 96c02f2041aa7821f4a9ea594c21910e977ebc4f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 7 Nov 2026 23:09:52 +0000 Subject: [PATCH 1/3] fix(web): Restore device defaults resets server settings even after picking a project on another settings page Settings keeps the "Applying settings for" project in the URL when you move from a scoped page such as Threads to General, where the scope picker is hidden. Restore device defaults planned against that hidden project scope, so every environment-wide server setting was dropped without a warning and the dialog kept listing them. Restore now reads and writes the all-environments scope, so it resets every connected environment and its dialog lists what it will change. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/SettingsPanels.tsx | 4 +- .../settings/SettingsRestore.browser.test.tsx | 144 ++++++++++++++++++ .../components/settings/useScopedSettings.ts | 24 +++ 3 files changed, 170 insertions(+), 2 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 c53ca8a2dc71a..0e673598ef83b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -51,6 +51,7 @@ import { useTheme, } from "../../hooks/useTheme"; import { + useAllEnvironmentsSettings, useScopedSettings, useScopedSettingsMixed, useUpdateScopedSettings, @@ -431,8 +432,7 @@ export function useSettingsRestore(onRestored?: () => void) { clearThemeHalves, themeHalves, } = useTheme(); - const settings = useScopedSettings(); - const updateSettings = useUpdateScopedSettings(); + const { settings, updateSettings } = useAllEnvironmentsSettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, 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 0000000000000..57c86a10ef7eb --- /dev/null +++ b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx @@ -0,0 +1,144 @@ +import { RegistryContext } from "@effect/atom-react"; +import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } 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[], + 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 })) 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 { appAtomRegistry } from "../../rpc/atomRegistry"; +import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; +import { useSettingsRestore } 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, +}; +const environments = [primaryId, remoteId].map((environmentId) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected" }, + serverConfig: { + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + settings: customized, + environment: { platform: { machine: "server" }, capabilities: { projectOrganization: true } }, + }, +})); + +function setup() { + vi.clearAllMocks(); + 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("useSettingsRestore", () => { + it("restores every environment's defaults under a project scope", async () => { + const projectKey = setup(); + let restored!: () => void; + const done = new Promise((resolve) => { + restored = resolve; + }); + function RestoreHarness() { + const { changedSettingLabels, restoreDefaults } = useSettingsRestore(restored); + return ( + <> +

{changedSettingLabels.join("|")}

+ + + ); + } + await using screen = await render( + + {}}> + + + , + ); + + await vi.waitFor(() => + expect(screen.getByTestId("labels").element().textContent).toBe( + "Auto-settle inactive threads|Provider update checks", + ), + ); + await userEvent.click(screen.getByRole("button", { name: "Restore device defaults" })); + await done; + + 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/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 5fb9d048a9e3d..0b0bcedc8b60c 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -15,6 +15,7 @@ import { } from "@t3tools/contracts"; import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; @@ -26,9 +27,11 @@ import { planScopedSettingsPatch, scopedSettingsAreMixed, scopedSettingsSource, + selectScopedSettingsEnvironments, type ProjectOverrideEntry, type ScopedSettingsPatch, } from "./scopedSettings"; +import { resolveSettingsScope } from "./settingsScope"; export function useScopedSettings( selector?: (settings: UnifiedSettings) => T, @@ -105,6 +108,27 @@ export function useUpdateScopedSettings() { run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); } +/** Ignores the settings URL scope: writes every connected environment, reads the primary one. */ +export function useAllEnvironmentsSettings() { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const scope = resolveSettingsScope({}, [], environments); + const { environment } = selectScopedSettingsEnvironments( + scope, + environments, + primaryEnvironmentId, + ); + const settings = useMergedSettings( + environment?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + ); + const run = useRunScopedPlan(); + return { + settings, + updateSettings: (patch: ScopedSettingsPatch) => + run(planScopedSettingsPatch(scope, environments, patch)), + }; +} + export function useClearScopedSettings() { const context = useOptionalSettingsScope(); const run = useRunScopedPlan(); From 4fc3bda4d64654cdb742180663ccf35270cb4f7b Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 00:54:04 +0000 Subject: [PATCH 2/3] test(web): Restore defaults test starts from default client settings Browser test files share localStorage, and an earlier file can leave a client setting such as word wrap changed, which added it to the labels the test asserts. The test now writes default client settings in setup(). Co-Authored-By: Claude Opus 5.5 --- .../src/components/settings/SettingsRestore.browser.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx index 57c86a10ef7eb..7d2b69307e2ee 100644 --- a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx +++ b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx @@ -43,6 +43,7 @@ vi.mock(import("~/localApi"), async (original) => ({ readLocalApi: (() => ({ dialogs: { confirm: state.confirm } })) as never, })); +import { writeBrowserClientSettings } from "../../clientPersistenceStorage"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; import { useSettingsRestore } from "./SettingsPanels"; @@ -68,6 +69,8 @@ const environments = [primaryId, remoteId].map((environmentId) => ({ function setup() { vi.clearAllMocks(); + // Browser test files share localStorage, and an earlier file may leave client settings changed. + writeBrowserClientSettings(DEFAULT_CLIENT_SETTINGS); state.projects = [primaryId, remoteId].map((environmentId) => ({ id: ProjectId.make(`project-${environmentId}`), environmentId, From 4f050331fdbe92969c1640e6ebd016f21964c64a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 01:48:31 +0000 Subject: [PATCH 3/3] fix(web): Restore defaults lists settings changed on any environment Restore resets every connected environment, so its dialog labels and enabled state now read a change on any of them instead of only the primary environment. Co-Authored-By: Claude Opus 5.5 --- .../components/settings/SettingsPanels.tsx | 2 +- .../settings/SettingsRestore.browser.test.tsx | 53 +++++++++++++------ .../src/components/settings/scopedSettings.ts | 15 ++++++ .../components/settings/useScopedSettings.ts | 19 +++---- 4 files changed, 61 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 0e673598ef83b..43175f9b07a29 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -432,7 +432,7 @@ export function useSettingsRestore(onRestored?: () => void) { clearThemeHalves, themeHalves, } = useTheme(); - const { settings, updateSettings } = useAllEnvironmentsSettings(); + const { changedSettings: settings, updateSettings } = useAllEnvironmentsSettings(); const isTextGenerationModelDirty = !Equal.equals( settings.textGenerationModelSelection ?? null, diff --git a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx index 7d2b69307e2ee..bf5d829d29037 100644 --- a/apps/web/src/components/settings/SettingsRestore.browser.test.tsx +++ b/apps/web/src/components/settings/SettingsRestore.browser.test.tsx @@ -1,5 +1,10 @@ import { RegistryContext } from "@effect/atom-react"; -import { DEFAULT_SERVER_SETTINGS, EnvironmentId, ProjectId } from "@t3tools/contracts"; +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"; @@ -11,6 +16,7 @@ import type { Project } from "../../types"; const state = vi.hoisted(() => ({ projects: [] as Project[], + environments: [] as unknown[], updateSettings: vi.fn< (request: { @@ -23,7 +29,7 @@ const state = vi.hoisted(() => ({ vi.mock(import("~/state/environments"), async (original) => ({ ...(await original()), - useEnvironments: (() => ({ environments })) as never, + useEnvironments: (() => ({ environments: state.environments })) as never, usePrimaryEnvironmentId: () => primaryId, })); vi.mock(import("~/state/entities"), async (original) => ({ @@ -56,21 +62,26 @@ const customized = { sidebarAutoSettleAfterDays: 30, enableProviderUpdateChecks: !DEFAULT_SERVER_SETTINGS.enableProviderUpdateChecks, }; -const environments = [primaryId, remoteId].map((environmentId) => ({ - environmentId, - label: environmentId, - connection: { phase: "connected" }, - serverConfig: { - keybindings: DEFAULT_RESOLVED_KEYBINDINGS, - settings: customized, - environment: { platform: { machine: "server" }, capabilities: { projectOrganization: true } }, - }, -})); -function setup() { +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, @@ -100,8 +111,15 @@ function setup() { } describe("useSettingsRestore", () => { - it("restores every environment's defaults under a project scope", async () => { - const projectKey = setup(); + it.each([ + { name: "a project scope", primarySettings: customized, search: "project" }, + { + name: "a machine scope, changed only on a remote environment", + primarySettings: DEFAULT_SERVER_SETTINGS, + search: "machine", + }, + ] as const)("restores every environment's defaults under $name", async (row) => { + const projectKey = setup(row.primarySettings); let restored!: () => void; const done = new Promise((resolve) => { restored = resolve; @@ -119,7 +137,10 @@ describe("useSettingsRestore", () => { } await using screen = await render( - {}}> + {}} + > , diff --git a/apps/web/src/components/settings/scopedSettings.ts b/apps/web/src/components/settings/scopedSettings.ts index 2c771db70f15c..ad6df02b22c8c 100644 --- a/apps/web/src/components/settings/scopedSettings.ts +++ b/apps/web/src/components/settings/scopedSettings.ts @@ -1,5 +1,6 @@ import { ClientSettingsSchema, + DEFAULT_SERVER_SETTINGS, type ClientSettingsPatch, type EnvironmentId, globalIdleCompaction, @@ -138,6 +139,20 @@ export function scopedSettingsAreMixed( ); } +/** Per key, the first target value that differs from the default, so a change on any target shows. */ +export function changedScopedSettings( + targets: readonly Pick[], +): ServerSettings { + return Object.fromEntries( + Object.entries(DEFAULT_SERVER_SETTINGS).map(([key, value]) => { + const changed = targets.find( + (target) => !Equal.equals(target.settings[key as keyof ServerSettings], value), + ); + return [key, changed ? changed.settings[key as keyof ServerSettings] : value]; + }), + ) as ServerSettings; +} + /** * Servers store the idle compaction mode differently by version, so selected * environments are compared by the mode each resolves to, and "Never" is offered diff --git a/apps/web/src/components/settings/useScopedSettings.ts b/apps/web/src/components/settings/useScopedSettings.ts index 0b0bcedc8b60c..58937adcb779e 100644 --- a/apps/web/src/components/settings/useScopedSettings.ts +++ b/apps/web/src/components/settings/useScopedSettings.ts @@ -15,16 +15,18 @@ import { } from "@t3tools/contracts"; import { persistClientSettingsPatch, useMergedSettings } from "../../hooks/useSettings"; -import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { useEnvironments } from "../../state/environments"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { toastManager } from "../ui/toast"; import { useOptionalSettingsScope, useSettingsScope } from "./SettingsScopeContext"; import { + changedScopedSettings, persistScopedSettingsPatch, planProjectOverridesClear, planScopedSettingsClear, planScopedSettingsPatch, + resolveScopedSettingsTargets, scopedSettingsAreMixed, scopedSettingsSource, selectScopedSettingsEnvironments, @@ -108,22 +110,17 @@ export function useUpdateScopedSettings() { run(planScopedSettingsPatch(scope, environments, patch), options?.onFailed); } -/** Ignores the settings URL scope: writes every connected environment, reads the primary one. */ +/** Ignores the settings URL scope: writes every connected environment and reads a change on any of them. */ export function useAllEnvironmentsSettings() { const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); const scope = resolveSettingsScope({}, [], environments); - const { environment } = selectScopedSettingsEnvironments( - scope, - environments, - primaryEnvironmentId, - ); - const settings = useMergedSettings( - environment?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); + const changedSettings = useMergedSettings( + changedScopedSettings(resolveScopedSettingsTargets(scope, connectedEnvironments)), ); const run = useRunScopedPlan(); return { - settings, + changedSettings, updateSettings: (patch: ScopedSettingsPatch) => run(planScopedSettingsPatch(scope, environments, patch)), };