Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/ProviderInstanceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
readonly onDelete?: (() => void) | undefined;
readonly headerAction?: ReactNode | undefined;
readonly setup?: ReactNode;
Expand Down Expand Up @@ -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<ProviderInstanceEnvironmentVariable>) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,67 @@
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<ComponentProps<typeof ProviderModelsSection>>) {
return (
<ProviderModelsSection
instanceId={ProviderInstanceId.make("claude")}
driverKind={ProviderDriverKind.make("claude")}
models={[]}
customModels={[]}
canManageCustomModels
hiddenModels={[]}
favoriteModels={["retired-model"]}
favoriteModels={[]}
modelOrder={[]}
onChange={() => {}}
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<boolean>((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();
});
30 changes: 22 additions & 8 deletions apps/web/src/components/settings/ProviderModelsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ interface ProviderModelsSectionProps {
readonly hiddenModels: ReadonlyArray<string>;
readonly favoriteModels: ReadonlyArray<string>;
readonly modelOrder: ReadonlyArray<string>;
readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => void;
/** Resolves true once the custom models are saved. */
readonly onChange: (next: ReadonlyArray<CustomModelDefinition>) => Promise<boolean>;
readonly onHiddenModelsChange: (
next: ReadonlyArray<string>,
affectedModels: ReadonlyArray<string>,
Expand Down Expand Up @@ -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<string | null>(null);
const [editingSlug, setEditingSlug] = useState<string | null>(null);
Expand Down Expand Up @@ -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.");
Expand All @@ -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);
Expand All @@ -226,15 +239,15 @@ 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);
};

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);
};

Expand Down Expand Up @@ -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);
Expand All @@ -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}
/>
<div className="flex shrink-0 gap-2">
<Button size="sm" variant="outline" onClick={handleAdd}>
<Button size="sm" variant="outline" disabled={isSaving} onClick={handleAdd}>
Add
</Button>
<Button size="sm" variant="ghost" onClick={cancelAdd}>
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/components/settings/ProviderSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,7 @@ export function EnvironmentProviderSettings({
description: error instanceof Error ? error.message : "The settings update failed.",
});
}
return result._tag !== "Failure";
};

const updateInstance = useStableCallback(
Expand All @@ -821,7 +822,7 @@ export function EnvironmentProviderSettings({
isDisabling &&
resolveAppModelSelectionState(settings, serverProviders).instanceId === instanceId &&
readEnvironmentScope(environmentId, AuthSettingsWriteScope);
void persistInstance(
return persistInstance(
instanceId,
next,
shouldClearTextGen ? DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection : undefined,
Expand Down Expand Up @@ -1197,11 +1198,11 @@ interface ProviderInstanceRowActions {
instanceId: ProviderInstanceId,
previous: ProviderInstanceConfig,
next: ProviderInstanceConfig,
) => void;
) => Promise<boolean>;
readonly enableInstance: (
instanceId: ProviderInstanceId,
next: ProviderInstanceConfig,
) => Promise<void>;
) => Promise<boolean>;
readonly deleteInstance: (
instanceId: ProviderInstanceId,
instance: ProviderInstanceConfig,
Expand Down
Loading