From 58b2968da11246be34e7b1b5d1a01d8d84319944 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:20:11 -0500 Subject: [PATCH 1/7] fix(web): interval settings save when editing ends, not on every keystroke Typing 120 over 300 in the provider health check interval saved 30, 3, 0 (off), 1 and 12 seconds on the way. The Background activity interval fields had the same handler. They now keep a local draft and save on blur or a stepper press; clearing the field and leaving restores the saved value instead of turning the check off. Co-Authored-By: Claude Opus 5.5 --- .../settings/BackgroundActivitySettings.tsx | 93 +++++-------------- .../settings/IntervalSecondsField.tsx | 62 +++++++++++++ ...SettingsPanel.environment.browser.test.tsx | 21 +++++ .../settings/ProviderSettingsPanel.tsx | 30 ++---- 4 files changed, 110 insertions(+), 96 deletions(-) create mode 100644 apps/web/src/components/settings/IntervalSecondsField.tsx diff --git a/apps/web/src/components/settings/BackgroundActivitySettings.tsx b/apps/web/src/components/settings/BackgroundActivitySettings.tsx index 9892f9ae753f1..67a58d625d001 100644 --- a/apps/web/src/components/settings/BackgroundActivitySettings.tsx +++ b/apps/web/src/components/settings/BackgroundActivitySettings.tsx @@ -22,13 +22,6 @@ import { DialogPopup, DialogTitle, } from "../ui/dialog"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -36,10 +29,10 @@ import { backgroundActivityOverrideSettings, backgroundActivitySharedPolicySettings, durationToSeconds, - normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { PolicyTooltip, SettingResetButton, SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -185,32 +178,21 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - automaticGitFetchInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { automaticGitFetchInterval: Duration.seconds(seconds) }, ), ) } - > - - - - - - + /> seconds
@@ -224,32 +206,21 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), ) } - > - - - - - - + /> seconds
@@ -262,32 +233,21 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorActiveInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorActiveInterval: Duration.seconds(seconds) }, ), ) } - > - - - - - - + /> seconds
@@ -300,32 +260,21 @@ function BackgroundActivityAdvancedDialog({

- + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - hostPowerMonitorIdleInterval: Duration.seconds( - normalizeIntervalSeconds(value, 5), - ), - }, + { hostPowerMonitorIdleInterval: Duration.seconds(seconds) }, ), ) } - > - - - - - - + /> seconds
diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx new file mode 100644 index 0000000000000..dd2e1a11a78c2 --- /dev/null +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; +import { normalizeIntervalSeconds } from "./SettingsPanels.logic"; + +/** + * Keeps typing local and saves on blur or a stepper press. Saving each keystroke would + * briefly apply partial values, such as 1 second on the way to 120, or 0 (off) after clearing. + */ +export function IntervalSecondsField({ + label, + value, + min, + step, + onCommit, +}: { + label: string; + value: number; + min: number; + step: number; + onCommit: (seconds: number) => void; +}) { + const [draft, setDraft] = useState(value); + const [savedValue, setSavedValue] = useState(value); + if (savedValue !== value) { + setSavedValue(value); + setDraft(value); + } + + return ( + { + if (next === null) { + setDraft(value); + return; + } + const seconds = normalizeIntervalSeconds(next, min); + setDraft(seconds); + if (seconds !== value) onCommit(seconds); + }} + > + + + + + + + ); +} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 851482d407c0b..7431db2aaf7a6 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -7,6 +7,7 @@ import { type ServerProvider, type UnifiedSettings, } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import { AsyncResult, type Atom } from "effect/reactivity"; import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -305,3 +306,23 @@ describe("EnvironmentProviderSettings routing", () => { }); }); }); + +describe("EnvironmentProviderSettings health check interval", () => { + it("saves a typed interval once, when editing ends", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + // Replacing 300 with 120 passes through 30, 3, an empty field, 1 and 12. + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await expect.element(input).toHaveValue("120"); + expect(state.updateSettings).not.toHaveBeenCalled(); + + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 78e7ea1435837..ed00afa408895 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -84,19 +84,13 @@ import { EmptyMedia, EmptyTitle, } from "../ui/empty"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { ScrollArea } from "../ui/scroll-area"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { AddProviderInstanceDialog } from "./AddProviderInstanceDialog"; import { ExpandableText } from "./ExpandableText"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { ProviderInstanceCard } from "./ProviderInstanceCard"; import { UsageProviderSettings } from "./UsageProviderSettings"; import { ProviderSetupSection, readAntigravityAuthMethod } from "./ProviderSetupSection"; @@ -109,7 +103,6 @@ import { backgroundActivityOverrideSettings, buildProviderInstanceUpdatePatch, durationToSeconds, - normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, } from "./SettingsPanels.logic"; import { @@ -1148,32 +1141,21 @@ export function EnvironmentProviderSettings({ !canWriteSettings && "opacity-50 select-none", )} > - + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, - { - providerHealthRefreshInterval: Duration.seconds( - normalizeIntervalSeconds(value), - ), - }, + { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), ) } - > - - - - - - + /> seconds } From 6694d845f8614cb95c7cdf85b1768b4f7a9fe1f0 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:41:26 -0500 Subject: [PATCH 2/7] test(web): a cleared interval field restores the saved value on blur Co-Authored-By: Claude Opus 5.5 --- ...roviderSettingsPanel.environment.browser.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 7431db2aaf7a6..fe7b35bb5f26d 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -325,4 +325,16 @@ describe("EnvironmentProviderSettings health check interval", () => { ), ).toEqual([120]); }); + + it("restores the saved interval when a cleared field loses focus", async () => { + await setup(); + await using _screen = await render(panel()); + const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}"); + await userEvent.tab(); + + await expect.element(input).toHaveValue("300"); + expect(state.updateSettings).not.toHaveBeenCalled(); + }); }); From 4eb855be11ec632ea86d89feb784e3a8e84e20d1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 20:45:59 -0500 Subject: [PATCH 3/7] fix(web): Source Control's Git fetch interval saves when editing ends Source Control edits the same automaticGitFetchInterval as the Background activity dialog, but still saved it on every keystroke. It now uses IntervalSecondsField, which takes an optional input label so the field keeps its accessible name. Co-Authored-By: Claude Opus 5.5 --- .../settings/IntervalSecondsField.tsx | 6 ++-- .../settings/SourceControlSettings.tsx | 33 ++++--------------- 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx index dd2e1a11a78c2..557b27cc6e5b7 100644 --- a/apps/web/src/components/settings/IntervalSecondsField.tsx +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -14,12 +14,14 @@ import { normalizeIntervalSeconds } from "./SettingsPanels.logic"; */ export function IntervalSecondsField({ label, + inputLabel = `${label.charAt(0).toUpperCase()}${label.slice(1)} in seconds`, value, min, step, onCommit, }: { label: string; + inputLabel?: string; value: number; min: number; step: number; @@ -52,9 +54,7 @@ export function IntervalSecondsField({ > - + diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index e694d9dc8cf6a..4259442a4ea02 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -20,6 +20,7 @@ import { import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; +import { IntervalSecondsField } from "./IntervalSecondsField"; import { cn } from "../../lib/utils"; import { useEnvironmentQuery } from "../../state/query"; import { sourceControlEnvironment } from "../../state/sourceControl"; @@ -35,13 +36,6 @@ import { EmptyTitle, } from "../ui/empty"; import { Skeleton } from "../ui/skeleton"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "../ui/number-field"; import { Switch } from "../ui/switch"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -86,13 +80,6 @@ function durationToSeconds(duration: Duration.Duration): number { return Math.round(Duration.toMillis(duration) / 1_000); } -function normalizeFetchIntervalSeconds(value: number | null): number { - if (value === null || !Number.isFinite(value)) { - return 0; - } - return Math.max(0, Math.round(value)); -} - function backgroundActivityOverrideSettings( current: BackgroundActivitySettings, overrides: BackgroundActivityOverridePatch, @@ -400,26 +387,20 @@ function GitFetchIntervalSettings() {

- + onCommit={(seconds) => updateSettings( backgroundActivityOverrideSettings(settings.backgroundActivity, { - automaticGitFetchInterval: Duration.seconds(normalizeFetchIntervalSeconds(value)), + automaticGitFetchInterval: Duration.seconds(seconds), }), ) } - > - - - - - - + /> seconds
From 50cf662934e2aea9b408af8eb4d28be726835547 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 21:39:07 -0500 Subject: [PATCH 4/7] fix(web): IntervalSecondsField compiles under React Compiler The template-literal default for inputLabel sat in the destructured parameters, which React Compiler cannot reorder, so it skipped the whole component. Derive the fallback label at the aria-label instead. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/settings/IntervalSecondsField.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx index 557b27cc6e5b7..aa1e922839eee 100644 --- a/apps/web/src/components/settings/IntervalSecondsField.tsx +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -14,7 +14,7 @@ import { normalizeIntervalSeconds } from "./SettingsPanels.logic"; */ export function IntervalSecondsField({ label, - inputLabel = `${label.charAt(0).toUpperCase()}${label.slice(1)} in seconds`, + inputLabel, value, min, step, @@ -54,7 +54,9 @@ export function IntervalSecondsField({ > - + From dd396913e14cbee3d4049533460af01c4b5a34ea Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 00:02:51 +0000 Subject: [PATCH 5/7] refactor(web): keep interval draft field scoped to current settings --- apps/web/src/components/settings/IntervalSecondsField.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx index aa1e922839eee..7de1de92eae22 100644 --- a/apps/web/src/components/settings/IntervalSecondsField.tsx +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -8,20 +8,14 @@ import { } from "../ui/number-field"; import { normalizeIntervalSeconds } from "./SettingsPanels.logic"; -/** - * Keeps typing local and saves on blur or a stepper press. Saving each keystroke would - * briefly apply partial values, such as 1 second on the way to 120, or 0 (off) after clearing. - */ export function IntervalSecondsField({ label, - inputLabel, value, min, step, onCommit, }: { label: string; - inputLabel?: string; value: number; min: number; step: number; @@ -55,7 +49,7 @@ export function IntervalSecondsField({ From c62d2b26dee69e379fab97ff72dc7cf619e5a34e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 00:18:51 +0000 Subject: [PATCH 6/7] fix(web): preserve consecutive interval commits before settings sync --- .../web/src/components/settings/IntervalSecondsField.tsx | 9 +++++++-- .../ProviderSettingsPanel.environment.browser.test.tsx | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx index 7de1de92eae22..ffc55d20b8d83 100644 --- a/apps/web/src/components/settings/IntervalSecondsField.tsx +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -23,8 +23,10 @@ export function IntervalSecondsField({ }) { const [draft, setDraft] = useState(value); const [savedValue, setSavedValue] = useState(value); + const [committedValue, setCommittedValue] = useState(value); if (savedValue !== value) { setSavedValue(value); + setCommittedValue(value); setDraft(value); } @@ -38,12 +40,15 @@ export function IntervalSecondsField({ onValueChange={setDraft} onValueCommitted={(next) => { if (next === null) { - setDraft(value); + setDraft(committedValue); return; } const seconds = normalizeIntervalSeconds(next, min); setDraft(seconds); - if (seconds !== value) onCommit(seconds); + if (seconds !== committedValue) { + setCommittedValue(seconds); + onCommit(seconds); + } }} > diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index 86e019896a18b..f6ae232fc2dd2 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -365,6 +365,14 @@ describe("EnvironmentProviderSettings health check interval", () => { await using _screen = await render(panel()); const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); + await userEvent.type(input, "{ArrowUp}{ArrowDown}"); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([330, 300]); + state.updateSettings.mockClear(); + // Replacing 300 with 120 passes through 30, 3, an empty field, 1 and 12. await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); await expect.element(input).toHaveValue("120"); From ab75817da997b8a2fbf72429c5033926a42116d3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 00:31:45 +0000 Subject: [PATCH 7/7] fix(web): restore interval drafts after rejected settings saves --- .../settings/BackgroundActivitySettings.tsx | 12 +++-- .../settings/IntervalSecondsField.tsx | 44 ++++++++++++------- ...SettingsPanel.environment.browser.test.tsx | 17 ++++++- .../settings/ProviderSettingsPanel.tsx | 3 +- apps/web/src/hooks/useSettings.ts | 32 ++++++++++---- 5 files changed, 79 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/settings/BackgroundActivitySettings.tsx b/apps/web/src/components/settings/BackgroundActivitySettings.tsx index 67a58d625d001..f52be354944a6 100644 --- a/apps/web/src/components/settings/BackgroundActivitySettings.tsx +++ b/apps/web/src/components/settings/BackgroundActivitySettings.tsx @@ -183,13 +183,14 @@ function BackgroundActivityAdvancedDialog({ value={automaticGitFetchIntervalSeconds} min={0} step={5} - onCommit={(seconds) => + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { automaticGitFetchInterval: Duration.seconds(seconds) }, ), + options, ) } /> @@ -211,13 +212,14 @@ function BackgroundActivityAdvancedDialog({ value={providerHealthRefreshIntervalSeconds} min={0} step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} - onCommit={(seconds) => + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), + options, ) } /> @@ -238,13 +240,14 @@ function BackgroundActivityAdvancedDialog({ value={hostPowerMonitorActiveIntervalSeconds} min={5} step={5} - onCommit={(seconds) => + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { hostPowerMonitorActiveInterval: Duration.seconds(seconds) }, ), + options, ) } /> @@ -265,13 +268,14 @@ function BackgroundActivityAdvancedDialog({ value={hostPowerMonitorIdleIntervalSeconds} min={5} step={30} - onCommit={(seconds) => + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { hostPowerMonitorIdleInterval: Duration.seconds(seconds) }, ), + options, ) } /> diff --git a/apps/web/src/components/settings/IntervalSecondsField.tsx b/apps/web/src/components/settings/IntervalSecondsField.tsx index ffc55d20b8d83..3480e97de3cf7 100644 --- a/apps/web/src/components/settings/IntervalSecondsField.tsx +++ b/apps/web/src/components/settings/IntervalSecondsField.tsx @@ -19,36 +19,50 @@ export function IntervalSecondsField({ value: number; min: number; step: number; - onCommit: (seconds: number) => void; + onCommit: (seconds: number, options: { onFailed: () => void }) => void; }) { - const [draft, setDraft] = useState(value); - const [savedValue, setSavedValue] = useState(value); - const [committedValue, setCommittedValue] = useState(value); - if (savedValue !== value) { - setSavedValue(value); - setCommittedValue(value); - setDraft(value); + const [field, setField] = useState({ + saved: value, + draft: value as number | null, + committed: value, + revision: 0, + }); + if (field.saved !== value) { + setField({ ...field, saved: value, draft: value, committed: value }); } return ( setField((current) => ({ ...current, draft }))} onValueCommitted={(next) => { if (next === null) { - setDraft(committedValue); + setField((current) => ({ ...current, draft: current.saved })); return; } const seconds = normalizeIntervalSeconds(next, min); - setDraft(seconds); - if (seconds !== committedValue) { - setCommittedValue(seconds); - onCommit(seconds); + if (seconds === field.committed) { + setField((current) => ({ ...current, draft: seconds })); + return; } + const revision = field.revision + 1; + setField((current) => ({ ...current, draft: seconds, committed: seconds, revision })); + onCommit(seconds, { + onFailed: () => + setField((current) => + current.revision !== revision + ? current + : { + ...current, + committed: current.saved, + draft: current.draft === seconds ? current.saved : current.draft, + }, + ), + }); }} > diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx index f6ae232fc2dd2..79322dc5ff6ee 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx @@ -360,7 +360,7 @@ describe("EnvironmentProviderSettings routing", () => { }); describe("EnvironmentProviderSettings health check interval", () => { - it("saves a typed interval once, when editing ends", async () => { + it("commits interval drafts and allows retrying rejected edits", async () => { await setup(); await using _screen = await render(panel()); const input = page.getByRole("textbox", { name: "Provider health check interval in seconds" }); @@ -384,6 +384,21 @@ describe("EnvironmentProviderSettings health check interval", () => { Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), ), ).toEqual([120]); + + await act(() => state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed?.()); + await expect.element(input).toHaveValue("300"); + await userEvent.type(input, "{Backspace}{Backspace}{Backspace}120"); + await userEvent.tab(); + expect( + state.updateSettings.mock.calls.map(([patch]) => + Duration.toSeconds(patch.backgroundActivity.overrides.providerHealthRefreshInterval), + ), + ).toEqual([120, 120]); + + const rejectPrevious = state.updateSettings.mock.calls.at(-1)?.[1]?.onFailed; + await userEvent.type(input, "{ArrowUp}"); + await act(() => rejectPrevious?.()); + await expect.element(input).toHaveValue("150"); }); it("restores the saved interval when a cleared field loses focus", async () => { diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index da5b42d15f729..103afe98620fa 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1121,13 +1121,14 @@ export function EnvironmentProviderSettings({ value={providerHealthRefreshIntervalSeconds} min={0} step={PROVIDER_HEALTH_INTERVAL_STEP_SECONDS} - onCommit={(seconds) => + onCommit={(seconds, options) => updateSettings( backgroundActivityOverrideSettings( settings.backgroundActivity, resolvedBackgroundActivity, { providerHealthRefreshInterval: Duration.seconds(seconds) }, ), + options, ) } /> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 2c24f13760092..43dfe3e045bb7 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -588,8 +588,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { reportFailure: false, }); const { environments } = useEnvironments(); - const persistServerSettings = async (request: Parameters[0]) => { + const persistServerSettings = async ( + request: Parameters[0], + onFailed?: () => void, + ) => { const result = await persist(request); + if (result._tag === "Failure") onFailed?.(); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const label = environments.find((environment) => environment.environmentId === request.environmentId) @@ -604,7 +608,10 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { return result; }; const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(true); - const updateSettings = (patch: UnifiedSettingsPatch) => { + const updateSettings = ( + patch: UnifiedSettingsPatch, + options?: { readonly onFailed?: () => void }, + ) => { const { serverPatch, clientPatch } = splitPatch(patch); const canWriteServerPatch = @@ -613,6 +620,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { readEnvironmentScope(environmentId, scope), ); if (Object.keys(serverPatch).length > 0 && !canWriteServerPatch) { + options?.onFailed?.(); toastManager.add({ type: "warning", title: "Setting not saved", @@ -622,18 +630,20 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0 && canWriteServerPatch) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. - const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => - toastManager.add({ + const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => { + options?.onFailed?.(); + return toastManager.add({ type: "warning", title: "Setting not saved", description, }); + }; if (Object.keys(localPatch).length > 0) { if (environmentId) { - void persistServerSettings({ - environmentId, - input: { patch: localPatch }, - }); + void persistServerSettings( + { environmentId, input: { patch: localPatch } }, + options?.onFailed, + ); } else { warnUnsaved(); } @@ -688,6 +698,12 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { result: await persist(request), })), ).then((outcomes) => { + if ( + deniedLabels.length > 0 || + outcomes.some(({ result }) => result._tag === "Failure") + ) { + options?.onFailed?.(); + } const failures = outcomes.flatMap(({ label, result }) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; const error = squashAtomCommandFailure(result);