diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 05a7471d44e34..3c92248b62c24 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -676,7 +676,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; @@ -877,7 +877,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 39583592b7360..175701dfe49a1 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); @@ -226,7 +239,7 @@ export function ProviderModelsSection({ const handleRemove = (slug: string) => { if (!canManageCustomModels) return; if (editingSlug === slug) setEditingSlug(null); - onChange(customModels.filter((entry) => entry.slug !== slug)); + void onChange(customModels.filter((entry) => entry.slug !== slug)); onModelOrderChange(modelOrder.filter((model) => model !== slug)); onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); setError(null); @@ -234,7 +247,7 @@ export function ProviderModelsSection({ const handleSaveEdit = (next: CustomModelDefinition) => { if (!canManageCustomModels) return; - onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); + void onChange(customModels.map((entry) => (entry.slug === next.slug ? next : entry))); setEditingSlug(null); }; @@ -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); @@ -424,13 +438,13 @@ export function ProviderModelsSection({ } if (event.key !== "Enter") return; event.preventDefault(); - handleAdd(); + void handleAdd(); }} placeholder={driverKind ? CUSTOM_MODEL_PLACEHOLDER_BY_KIND[driverKind] : "model-slug"} spellCheck={false} />
-