diff --git a/frontend/src/shell/SettingsDialog.test.tsx b/frontend/src/shell/SettingsDialog.test.tsx new file mode 100644 index 00000000..86ea5b27 --- /dev/null +++ b/frontend/src/shell/SettingsDialog.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { server } from "../test/msw"; +import { SettingsDialog } from "./SettingsDialog"; + +function renderDialog() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + server.use( + http.get("http://localhost:8787/workspaces", () => + HttpResponse.json([ + { name: "psd", file: "psd.yaml" }, + { name: "tht-test", file: "tht-test.yaml" }, + ]), + ), + http.get("http://localhost:8787/models", () => + HttpResponse.json({ + models: [ + { provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }, + { provider: "anthropic", id: "claude-opus-4-8", name: "Claude Opus 4.8", reasoning: true }, + ], + }), + ), + http.get("http://localhost:8787/settings", () => + HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "medium" }), + ), + ); +}); + +test("opens and pre-selects current settings", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /settings/i })); + + const workspace = (await screen.findByLabelText(/workspace/i)) as HTMLSelectElement; + expect(workspace.value).toBe("psd"); + const thinking = screen.getByLabelText(/thinking/i) as HTMLSelectElement; + expect(thinking.value).toBe("medium"); + // thinking options are exactly low/medium/high + expect(Array.from(thinking.options).map((o) => o.value)).toEqual(["low", "medium", "high"]); +}); + +test("provider change filters the model list", async () => { + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /settings/i })); + + const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement; + await userEvent.selectOptions(provider, "anthropic"); + const model = screen.getByLabelText(/modello/i) as HTMLSelectElement; + const optionValues = Array.from(model.options).map((o) => o.value); + expect(optionValues).toContain("claude-opus-4-8"); + expect(optionValues).not.toContain("glm-5.2"); +}); + +test("Salva PUTs the selected settings", async () => { + let body: unknown = null; + server.use( + http.put("http://localhost:8787/settings", async ({ request }) => { + body = await request.json(); + return HttpResponse.json(await request.json()); + }), + ); + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /settings/i })); + await screen.findByLabelText(/workspace/i); + await userEvent.selectOptions(screen.getByLabelText(/thinking/i), "high"); + await userEvent.click(screen.getByRole("button", { name: /salva/i })); + + await waitFor(() => + expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }), + ); +}); + +test("degrades to free-text provider/model when models list is empty", async () => { + server.use( + http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })), + http.get("http://localhost:8787/settings", () => + HttpResponse.json({ workspace: "psd", provider: "", model: "", thinking: "low" }), + ), + ); + renderDialog(); + await userEvent.click(screen.getByRole("button", { name: /settings/i })); + expect(await screen.findByTestId("provider-freetext")).toHaveProperty("tagName", "INPUT"); + expect(screen.getByTestId("model-freetext")).toHaveProperty("tagName", "INPUT"); +}); diff --git a/frontend/src/shell/SettingsDialog.tsx b/frontend/src/shell/SettingsDialog.tsx new file mode 100644 index 00000000..24211148 --- /dev/null +++ b/frontend/src/shell/SettingsDialog.tsx @@ -0,0 +1,228 @@ +// frontend/src/shell/SettingsDialog.tsx +import { useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { Button } from "../components/ui/button"; +import { listWorkspaces } from "../api/workspaces"; +import { listModels } from "../api/models"; +import { getSettings, putSettings } from "../api/settings"; + +const THINKING_LEVELS = ["low", "medium", "high"] as const; + +export function SettingsDialog() { + const [open, setOpen] = useState(false); + const [workspace, setWorkspace] = useState(""); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [thinking, setThinking] = useState("medium"); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const queryClient = useQueryClient(); + + const { data: workspaces = [] } = useQuery({ + queryKey: ["workspaces"], + queryFn: listWorkspaces, + enabled: open, + }); + const { data: modelsData } = useQuery({ + queryKey: ["models"], + queryFn: listModels, + enabled: open, + }); + const { data: current } = useQuery({ + queryKey: ["settings"], + queryFn: getSettings, + enabled: open, + }); + + const models = useMemo(() => modelsData?.models ?? [], [modelsData]); + const hasModels = models.length > 0; + const providers = useMemo( + () => Array.from(new Set(models.map((m) => m.provider))), + [models], + ); + const modelsForProvider = useMemo( + () => models.filter((m) => !provider || m.provider === provider), + [models, provider], + ); + + // Hydrate the form once the current settings arrive. + useEffect(() => { + if (!current) return; + setWorkspace(current.workspace ?? ""); + setProvider(current.provider ?? ""); + setModel(current.model ?? ""); + setThinking(current.thinking && THINKING_LEVELS.includes(current.thinking as never) + ? current.thinking + : "medium"); + }, [current]); + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setBusy(true); + try { + await putSettings({ + workspace: workspace || undefined, + provider: provider || undefined, + model: model || undefined, + thinking, + }); + await queryClient.invalidateQueries({ queryKey: ["settings"] }); + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Errore nel salvataggio dei settings."); + } finally { + setBusy(false); + } + } + + return ( + + setOpen(true)} + > + Settings + + + + Settings + + + + + Workspace + + setWorkspace(e.target.value)} + className="w-full border rounded px-2 py-1 text-sm" + > + {workspaces.length === 0 ? ( + — + ) : ( + workspaces.map((w) => ( + + {w.name} + + )) + )} + + + + + + Provider + + {hasModels ? ( + { + setProvider(e.target.value); + setModel(""); // reset model when provider changes + }} + className="w-full border rounded px-2 py-1 text-sm" + > + — tutti — + {providers.map((p) => ( + + {p} + + ))} + + ) : ( + setProvider(e.target.value)} + placeholder="es. anthropic" + className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1" + data-testid="provider-freetext" + /> + )} + + + + + Modello + + {hasModels ? ( + setModel(e.target.value)} + className="w-full border rounded px-2 py-1 text-sm" + > + — default — + {modelsForProvider.map((m) => ( + + {m.provider}/{m.name} + + ))} + + ) : ( + setModel(e.target.value)} + placeholder="es. glm-5.2" + className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1" + data-testid="model-freetext" + /> + )} + + + + + Thinking + + setThinking(e.target.value)} + className="w-full border rounded px-2 py-1 text-sm" + > + {THINKING_LEVELS.map((lvl) => ( + + {lvl} + + ))} + + + + {!hasModels && ( + + Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero. + + )} + {error && ( + + {error} + + )} + + + setOpen(false)}> + Annulla + + + {busy ? "Salvataggio…" : "Salva"} + + + + + + ); +}
+ Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero. +
+ {error} +