From 467ae90e10be035eb40ac7db7f5d7857f92f7026 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:00:21 +1100 Subject: [PATCH 1/2] fix(web): retain custom model drafts until saved --- .../settings/ProviderInstanceCard.tsx | 4 +- .../ProviderModelsSection.browser.test.tsx | 55 ++++++++++++++++--- .../settings/ProviderModelsSection.tsx | 24 ++++++-- .../settings/ProviderSettingsPanel.tsx | 7 ++- 4 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 36a737959c508..14e7afebd6f59 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -672,7 +672,7 @@ interface ProviderInstanceCardProps { readonly onSelect?: (() => void) | undefined; readonly readOnly?: boolean | undefined; readonly canWriteSettings?: boolean; - readonly onUpdate: (nextInstance: ProviderInstanceConfig) => void; + readonly onUpdate: (nextInstance: ProviderInstanceConfig) => Promise; readonly onDelete?: (() => void) | undefined; readonly headerAction?: ReactNode | undefined; readonly setup?: ReactNode; @@ -873,7 +873,7 @@ export function ProviderInstanceCard({ next.map(toCustomModelSetting), ); const { config: _omit, ...rest } = instance; - onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); + return onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }); const updateEnvironment = (environment: ReadonlyArray) => { diff --git a/apps/web/src/components/settings/ProviderModelsSection.browser.test.tsx b/apps/web/src/components/settings/ProviderModelsSection.browser.test.tsx index bf193053e94a6..a0661fd3e8742 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.browser.test.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.browser.test.tsx @@ -1,12 +1,13 @@ import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import type { ComponentProps } from "react"; import { expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import { ProviderModelsSection } from "./ProviderModelsSection"; -it("lists a favorite the provider no longer reports so it can be unstarred", async () => { - const onToggleFavorite = vi.fn(); - await using screen = await render( +function section(props: Partial>) { + return ( {}} + onChange={async () => true} onHiddenModelsChange={() => {}} onFavoriteModelsChange={() => {}} - onToggleFavorite={onToggleFavorite} + onToggleFavorite={() => {}} onModelOrderChange={() => {}} - />, + {...props} + /> + ); +} + +it("lists a favorite the provider no longer reports so it can be unstarred", async () => { + const onToggleFavorite = vi.fn(); + await using screen = await render( + section({ favoriteModels: ["retired-model"], onToggleFavorite }), ); await screen.getByRole("button", { name: "Remove retired-model from favorites" }).click(); expect(onToggleFavorite).toHaveBeenCalledWith("retired-model"); }); + +it("keeps a custom model draft until it saves, so a failed save can be retried", async () => { + const saves: Array<(saved: boolean) => void> = []; + const onChange = () => new Promise((resolve) => saves.push(resolve)); + await using screen = await render(section({ onChange })); + const addCustomModel = screen.getByRole("button", { name: "Add custom model" }); + const input = screen.getByRole("textbox"); + const add = screen.getByRole("button", { name: "Add", exact: true }); + + await userEvent.click(addCustomModel); + await userEvent.type(input, "my-model{Enter}"); + await expect.element(add).toBeDisabled(); + await userEvent.keyboard("{Enter}"); + expect(saves).toHaveLength(1); + await act(() => saves[0]!(false)); + await expect.element(add).toBeEnabled(); + await expect.element(input).toHaveValue("my-model"); + + await userEvent.click(add); + await act(() => saves[1]!(true)); + await expect.element(input).not.toBeInTheDocument(); + + // A save the user cancelled must not close the draft opened after it. + await userEvent.click(addCustomModel); + await userEvent.type(input, "other-model{Enter}"); + await userEvent.click(screen.getByRole("button", { name: "Cancel" })); + await userEvent.click(addCustomModel); + await act(() => saves[2]!(true)); + await expect.element(add).toBeEnabled(); +}); diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index a68f10eb56505..25dce3fce0d85 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -109,7 +109,8 @@ interface ProviderModelsSectionProps { readonly hiddenModels: ReadonlyArray; readonly favoriteModels: ReadonlyArray; readonly modelOrder: ReadonlyArray; - readonly onChange: (next: ReadonlyArray) => void; + /** Resolves true once the custom models are saved. */ + readonly onChange: (next: ReadonlyArray) => Promise; readonly onHiddenModelsChange: ( next: ReadonlyArray, affectedModels: ReadonlyArray, @@ -137,6 +138,8 @@ export function ProviderModelsSection({ }: ProviderModelsSectionProps) { const [input, setInput] = useState(""); const [isAdding, setIsAdding] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const addAttemptRef = useRef(0); const [filter, setFilter] = useState(""); const [error, setError] = useState(null); const [editingSlug, setEditingSlug] = useState(null); @@ -189,8 +192,8 @@ export function ProviderModelsSection({ const allBuiltInModelsHidden = builtInModels.length > 0 && builtInModels.every((model) => hiddenModels.includes(model.slug)); - const handleAdd = () => { - if (!canManageCustomModels || driverKind === "antigravity") return; + const handleAdd = async () => { + if (!canManageCustomModels || driverKind === "antigravity" || isSaving) return; const normalized = normalizeCustomModelSlug(input); if (!normalized) { setError("Enter a model slug."); @@ -209,15 +212,25 @@ export function ProviderModelsSection({ return; } + addAttemptRef.current += 1; + const attempt = addAttemptRef.current; + setIsSaving(true); + const saved = await onChange([ + ...customModels, + { slug: normalized, name: normalized, capabilities: null }, + ]); + setIsSaving(false); + // Cancel bumps the attempt, so a late save cannot close the draft opened after it. + if (!saved || attempt !== addAttemptRef.current) return; scrollToSlugRef.current = normalized; setFilter(""); - onChange([...customModels, { slug: normalized, name: normalized, capabilities: null }]); setInput(""); setError(null); setIsAdding(false); }; const cancelAdd = () => { + addAttemptRef.current += 1; setInput(""); setError(null); setIsAdding(false); @@ -411,6 +424,7 @@ export function ProviderModelsSection({ id={`provider-instance-${instanceId}-custom-model`} size="sm" autoFocus + readOnly={isSaving} value={input} onChange={(event) => { setInput(event.target.value); @@ -430,7 +444,7 @@ export function ProviderModelsSection({ spellCheck={false} />
-