diff --git a/apps/web/src/components/settings/BackgroundActivitySettings.tsx b/apps/web/src/components/settings/BackgroundActivitySettings.tsx
index 9892f9ae753f1..f52be354944a6 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,22 @@ function BackgroundActivityAdvancedDialog({
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- automaticGitFetchInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
+ { automaticGitFetchInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
@@ -224,32 +207,22 @@ function BackgroundActivityAdvancedDialog({
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
+ { providerHealthRefreshInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
@@ -262,32 +235,22 @@ function BackgroundActivityAdvancedDialog({
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- hostPowerMonitorActiveInterval: Duration.seconds(
- normalizeIntervalSeconds(value, 5),
- ),
- },
+ { hostPowerMonitorActiveInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
@@ -300,32 +263,22 @@ function BackgroundActivityAdvancedDialog({
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- hostPowerMonitorIdleInterval: Duration.seconds(
- normalizeIntervalSeconds(value, 5),
- ),
- },
+ { hostPowerMonitorIdleInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
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..3480e97de3cf7
--- /dev/null
+++ b/apps/web/src/components/settings/IntervalSecondsField.tsx
@@ -0,0 +1,77 @@
+import { useState } from "react";
+import {
+ NumberField,
+ NumberFieldDecrement,
+ NumberFieldGroup,
+ NumberFieldIncrement,
+ NumberFieldInput,
+} from "../ui/number-field";
+import { normalizeIntervalSeconds } from "./SettingsPanels.logic";
+
+export function IntervalSecondsField({
+ label,
+ value,
+ min,
+ step,
+ onCommit,
+}: {
+ label: string;
+ value: number;
+ min: number;
+ step: number;
+ onCommit: (seconds: number, options: { onFailed: () => void }) => void;
+}) {
+ 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) {
+ setField((current) => ({ ...current, draft: current.saved }));
+ return;
+ }
+ const seconds = normalizeIntervalSeconds(next, min);
+ 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 4ad9b3986bb3a..79322dc5ff6ee 100644
--- a/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx
+++ b/apps/web/src/components/settings/ProviderSettingsPanel.environment.browser.test.tsx
@@ -8,6 +8,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";
@@ -357,3 +358,58 @@ describe("EnvironmentProviderSettings routing", () => {
);
});
});
+
+describe("EnvironmentProviderSettings health check interval", () => {
+ 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" });
+
+ 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");
+ expect(state.updateSettings).not.toHaveBeenCalled();
+
+ await userEvent.tab();
+ expect(
+ state.updateSettings.mock.calls.map(([patch]) =>
+ 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 () => {
+ 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();
+ });
+});
diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx
index b5fb648ff97c2..103afe98620fa 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 { useSecretSources } from "./SecretSourceInput";
import { UsageProviderSettings } from "./UsageProviderSettings";
@@ -111,7 +105,6 @@ import {
backgroundActivityOverrideSettings,
buildProviderInstanceUpdatePatch,
durationToSeconds,
- normalizeIntervalSeconds,
PROVIDER_HEALTH_INTERVAL_STEP_SECONDS,
} from "./SettingsPanels.logic";
import {
@@ -1123,32 +1116,22 @@ export function EnvironmentProviderSettings({
!canWriteSettings && "opacity-50 select-none",
)}
>
-
+ onCommit={(seconds, options) =>
updateSettings(
backgroundActivityOverrideSettings(
settings.backgroundActivity,
resolvedBackgroundActivity,
- {
- providerHealthRefreshInterval: Duration.seconds(
- normalizeIntervalSeconds(value),
- ),
- },
+ { providerHealthRefreshInterval: Duration.seconds(seconds) },
),
+ options,
)
}
- >
-
-
-
-
-
-
+ />
seconds
}
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);