fix(webui): preserve preset deletion workflow

This commit is contained in:
Xubin Ren
2026-08-07 12:42:37 +08:00
parent 1b12fbae39
commit b77e1133cb
2 changed files with 6 additions and 90 deletions
+5 -21
View File
@@ -1467,21 +1467,10 @@ export function SettingsView({
) { ) {
return; return;
} }
const presetName = modelPresetPendingDelete.name;
const nextOrder = modelCallOrder.includes(presetName)
? modelCallOrder.filter((name) => name !== presetName)
: null;
if (nextOrder?.length === 0) return;
setSaving(true); setSaving(true);
try { try {
if (nextOrder) { const payload = await deleteModelConfiguration(token, modelPresetPendingDelete.name);
const orderedPayload = await updateModelCallOrder(token, nextOrder);
applyPayload(orderedPayload);
onModelNameChange(orderedPayload.agent.model || null);
}
const payload = await deleteModelConfiguration(token, presetName);
applyPayload(payload); applyPayload(payload);
onModelNameChange(payload.agent.model || null);
setModelPresetPendingDelete(null); setModelPresetPendingDelete(null);
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -3384,11 +3373,6 @@ function ModelsSettings({
const selectedPresetReferenced = Boolean( const selectedPresetReferenced = Boolean(
selectedPreset && callOrder.includes(selectedPreset.name), selectedPreset && callOrder.includes(selectedPreset.name),
); );
const selectedPresetOnlyActive = Boolean(
selectedPresetReferenced &&
selectedPreset &&
!callOrder.some((name) => name !== selectedPreset.name),
);
const callOrderBusy = orderSaving || saving; const callOrderBusy = orderSaving || saving;
const selectPreset = (preset: SettingsPayload["model_presets"][number]) => { const selectPreset = (preset: SettingsPayload["model_presets"][number]) => {
const toggleCurrentPreset = !creating && selectedPreset?.name === preset.name; const toggleCurrentPreset = !creating && selectedPreset?.name === preset.name;
@@ -3453,7 +3437,7 @@ function ModelsSettings({
key={key} key={key}
id="model-preset-editor" id="model-preset-editor"
data-testid="model-preset-editor" data-testid="model-preset-editor"
className="divide-y divide-border/45 bg-muted/10 motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200" className="divide-y divide-border/45 bg-muted/30 motion-reduce:animate-none animate-in fade-in-0 slide-in-from-top-1 duration-200"
> >
{creating ? ( {creating ? (
<div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5"> <div className="flex min-h-[52px] items-center px-4 py-3 sm:px-5">
@@ -3582,16 +3566,16 @@ function ModelsSettings({
size="sm" size="sm"
variant="ghost" variant="ghost"
className="rounded-full text-muted-foreground hover:text-destructive" className="rounded-full text-muted-foreground hover:text-destructive"
disabled={selectedPresetOnlyActive || saving || orderSaving} disabled={selectedPresetReferenced || saving || orderSaving}
aria-describedby={ aria-describedby={
selectedPresetOnlyActive ? "model-preset-delete-hint" : undefined selectedPresetReferenced ? "model-preset-delete-hint" : undefined
} }
onClick={() => onDeleteConfiguration(selectedPreset)} onClick={() => onDeleteConfiguration(selectedPreset)}
> >
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.actions.delete", "Delete")} {tx("settings.actions.delete", "Delete")}
</Button> </Button>
{selectedPresetOnlyActive ? ( {selectedPresetReferenced ? (
<span <span
id="model-preset-delete-hint" id="model-preset-delete-hint"
className="text-[11px] leading-4 text-muted-foreground" className="text-[11px] leading-4 text-muted-foreground"
+1 -69
View File
@@ -2167,7 +2167,7 @@ describe("SettingsView Apps catalog", () => {
expect(trigger).toHaveAttribute("aria-expanded", "true"); expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor"); expect(trigger).toHaveAttribute("aria-controls", "model-preset-editor");
expect(row.nextElementSibling).toBe(editor); expect(row.nextElementSibling).toBe(editor);
expect(editor).toHaveClass("slide-in-from-top-1", "bg-muted/10"); expect(editor).toHaveClass("slide-in-from-top-1", "bg-muted/30");
expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument(); expect(within(editor).getByDisplayValue("Primary")).toBeInTheDocument();
const deleteButton = within(editor).getByRole("button", { name: "Delete" }); const deleteButton = within(editor).getByRole("button", { name: "Delete" });
expect(deleteButton).toBeDisabled(); expect(deleteButton).toBeDisabled();
@@ -2182,74 +2182,6 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument(); expect(screen.queryByTestId("model-preset-editor")).not.toBeInTheDocument();
}); });
it("removes an active preset from the call order before deleting it", async () => {
const { payload, backupPreset } = settingsPayloadWithBackup();
const orderedPayload: SettingsPayload = {
...payload,
agent: {
...payload.agent,
model: backupPreset.model,
provider: backupPreset.provider,
resolved_provider: backupPreset.resolved_provider,
model_preset: backupPreset.name,
},
model_presets: payload.model_presets.map((preset) => ({
...preset,
active: preset.name === backupPreset.name,
})),
model_call_order: [backupPreset.name],
};
const deletedPayload: SettingsPayload = {
...orderedPayload,
model_presets: orderedPayload.model_presets.filter(
(preset) => preset.name !== "primary",
),
};
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
if (url.startsWith("/api/settings/model-call-order/update?")) {
return jsonResponse(orderedPayload);
}
if (url.startsWith("/api/settings/model-configurations/delete?")) {
return jsonResponse(deletedPayload);
}
return { ok: false, status: 404, json: async () => ({}) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await togglePresetEditor();
const deleteButton = screen.getByRole("button", { name: "Delete" });
expect(deleteButton).toBeEnabled();
fireEvent.click(deleteButton);
const dialog = await screen.findByRole("dialog", { name: "Delete model preset?" });
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
await waitFor(() => {
const urls = fetchMock.mock.calls.map(([input]) => String(input));
const orderCallIndex = urls.findIndex((url) =>
url.startsWith("/api/settings/model-call-order/update?"),
);
const deleteCallIndex = urls.findIndex((url) =>
url.startsWith("/api/settings/model-configurations/delete?"),
);
expect(orderCallIndex).toBeGreaterThanOrEqual(0);
expect(deleteCallIndex).toBeGreaterThan(orderCallIndex);
const orderUrl = new URL(urls[orderCallIndex], "http://nanobot.test");
expect(JSON.parse(orderUrl.searchParams.get("order") ?? "[]")).toEqual(["backup"]);
});
expect(screen.queryByTestId("model-call-order-row-primary")).not.toBeInTheDocument();
expect(screen.getByTestId("model-call-order-row-backup")).toBeInTheDocument();
});
it("drags model presets to reorder and saves the model call order immediately", async () => { it("drags model presets to reorder and saves the model call order immediately", async () => {
const { payload, backupPreset } = settingsPayloadWithBackup(); const { payload, backupPreset } = settingsPayloadWithBackup();
const updatedPayload: SettingsPayload = { const updatedPayload: SettingsPayload = {