From 85c1999467fdcb3b1ce26c734c1e62da73011b37 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 7 Nov 2026 18:02:12 +0000 Subject: [PATCH 1/2] fix(web): an invalid "Auto-compact after" value is rejected in the field instead of taking Claude offline Settings saved any text typed into Claude's "Auto-compact after" field, such as 50000, 300k or 300,000. The server then failed to decode the instance config and marked the Claude instance unavailable, so new Claude threads could not start until the field was cleared. Provider settings fields now carry a validator built from their own schema. The settings text input commits only values that schema decodes (empty stays valid where the field is omitted when empty). A rejected value stays in the field, marked aria-invalid, with a short error line beneath it. Co-Authored-By: Claude Opus 5.5 --- .../settings/ProviderSettingsForm.test.ts | 15 ++++ .../settings/ProviderSettingsForm.tsx | 83 +++++++++++++++---- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index d59eb5c1238b9..111f1731957bb 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -52,6 +52,20 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); + it("accepts only text values the field schema decodes", () => { + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent")); + expect(claude).toBeDefined(); + + const autoCompactWindow = deriveProviderSettingsFields(claude!).find( + (field) => field.key === "autoCompactWindow", + ); + expect(autoCompactWindow?.isValid).toBeDefined(); + + const isValid = autoCompactWindow!.isValid; + expect(["50000", "300,000", "300k"].map(isValid)).toEqual([false, false, false]); + expect(["300000", " 300000 ", "1000000", ""].map(isValid)).toEqual([true, true, true, true]); + }); + it("stores false boolean fields when true is the default", () => { const next = nextProviderConfigWithFieldValue( undefined, @@ -60,6 +74,7 @@ describe("ProviderSettingsForm helpers", () => { control: "switch", label: "Experimental", clearWhenEmpty: "omit", + isValid: () => true, defaultBooleanValue: true, }, false, diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 646dfd80edc20..b8930bfa1764a 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -42,6 +42,7 @@ export interface ProviderSettingsFieldModel { /** Typed `SettingsSecretValue`: may name a secret provider reference instead of a value. */ readonly acceptsSecretReference?: boolean | undefined; readonly defaultBooleanValue?: boolean | undefined; + readonly isValid: (value: string) => boolean; readonly options?: ReadonlyArray | undefined; } @@ -89,6 +90,15 @@ function readFieldBooleanDefault( return Option.isSome(decoded) && typeof decoded.value === "boolean" ? decoded.value : undefined; } +function makeFieldTextValidator( + fieldSchema: ProviderClientDefinition["settingsSchema"]["fields"][string], + clearWhenEmpty: ProviderSettingsFieldModel["clearWhenEmpty"], +) { + const decode = Schema.decodeUnknownOption(fieldSchema as Schema.Decoder); + return (value: string) => + (clearWhenEmpty === "omit" && value.trim().length === 0) || Option.isSome(decode(value)); +} + export function deriveProviderSettingsFields( definition: SettingsFormDefinition, value?: unknown, @@ -118,6 +128,7 @@ export function deriveProviderSettingsFields( const annotatedTitle = readFieldAnnotationString(fieldSchema, "title"); const annotatedDescription = readFieldAnnotationString(fieldSchema, "description"); + const clearWhenEmpty = formAnnotation.clearWhenEmpty ?? "omit"; return [ { key, @@ -136,7 +147,8 @@ export function deriveProviderSettingsFields( : formAnnotation.placeholder !== undefined ? { placeholder: formAnnotation.placeholder } : {}), - clearWhenEmpty: formAnnotation.clearWhenEmpty ?? "omit", + clearWhenEmpty, + isValid: makeFieldTextValidator(fieldSchema, clearWhenEmpty), ...(isSecretSetting(fieldSchema) ? { secret: true } : {}), ...(acceptsSecretReference(fieldSchema) ? { acceptsSecretReference: true } : {}), ...(formAnnotation.control === "switch" @@ -393,6 +405,56 @@ function SecretSettingsControl({ ); } +function ProviderSettingsTextInput({ + field, + value, + inputId, + descriptionId, + onChange, +}: { + readonly field: ProviderSettingsFieldModel; + readonly value: unknown; + readonly inputId: string; + readonly descriptionId: string | undefined; + readonly onChange: ProviderSettingsFormProps["onChange"]; +}) { + const saved = readProviderConfigString(value, field.key); + const [rejection, setRejection] = useState<{ draft: string; saved: string } | null>(null); + // A rejected draft stays only until the saved value changes underneath it. + const rejected = rejection?.saved === saved ? rejection.draft : null; + const errorId = `${inputId}-error`; + return ( +
+ { + if (!field.isValid(next)) { + setRejection({ draft: next, saved }); + return; + } + setRejection(null); + onChange(nextProviderConfigWithFieldValue(value, field, next)); + }} + spellCheck={false} + /> + {rejected !== null ? ( +

+ Enter a valid value. +

+ ) : null} +
+ ); +} + function ProviderSettingsSelect({ field, value, @@ -541,19 +603,12 @@ function ProviderSettingsFieldRow({ ) : null} ) : ( - { - onChange(nextProviderConfigWithFieldValue(value, field, next)); - }} - spellCheck={false} + ); From e79f54f5e299a1e939e317e1737e0f473a4cc397 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 17:44:04 +0000 Subject: [PATCH 2/2] fix(web): a rejected provider setting no longer returns when the saved value comes back A rejected draft was only hidden when the saved value changed, so a saved value going A to B and back to A showed the old draft and its error again. Clear it as soon as a different saved value arrives. Co-Authored-By: Claude Opus 5.5 --- .../ProviderSettingsForm.browser.test.tsx | 35 +++++++++++++++++++ .../settings/ProviderSettingsForm.tsx | 3 +- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/settings/ProviderSettingsForm.browser.test.tsx diff --git a/apps/web/src/components/settings/ProviderSettingsForm.browser.test.tsx b/apps/web/src/components/settings/ProviderSettingsForm.browser.test.tsx new file mode 100644 index 0000000000000..b0f21b59065bd --- /dev/null +++ b/apps/web/src/components/settings/ProviderSettingsForm.browser.test.tsx @@ -0,0 +1,35 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { userEvent } from "vite-plus/test/browser"; +import { describe, expect, it } from "vite-plus/test"; + +import { render } from "../../../test/render"; +import { providerClients } from "./providerDriverMeta"; +import { ProviderSettingsForm } from "./ProviderSettingsForm"; + +describe("ProviderSettingsForm text fields", () => { + it("drops a rejected draft once the saved value changes", async () => { + const claude = providerClients.get(ProviderDriverKind.make("claudeAgent"))!; + const form = (autoCompactWindow: string) => ( + {}} + /> + ); + await using screen = await render(form("300000")); + const input = screen.getByRole("textbox", { name: "Auto-compact after" }); + + await userEvent.fill(input, "300k"); + await userEvent.keyboard("{Enter}"); + await expect.element(input).toHaveValue("300k"); + await expect.element(input).toHaveAttribute("aria-invalid", "true"); + + await screen.rerender(form("500000")); + await expect.element(input).toHaveValue("500000"); + await screen.rerender(form("300000")); + await expect.element(input).toHaveValue("300000"); + await expect.element(input).toHaveAttribute("aria-invalid", "false"); + }); +}); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index b8930bfa1764a..86c9fd691986e 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -421,7 +421,8 @@ function ProviderSettingsTextInput({ const saved = readProviderConfigString(value, field.key); const [rejection, setRejection] = useState<{ draft: string; saved: string } | null>(null); // A rejected draft stays only until the saved value changes underneath it. - const rejected = rejection?.saved === saved ? rejection.draft : null; + if (rejection !== null && rejection.saved !== saved) setRejection(null); + const rejected = rejection?.draft ?? null; const errorId = `${inputId}-error`; return (