feat(frontend): SettingsDialog with workspace/provider/model/thinking selects
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<SettingsDialog />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
@@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSave} className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" htmlFor="set-workspace">
|
||||||
|
Workspace
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="set-workspace"
|
||||||
|
value={workspace}
|
||||||
|
onChange={(e) => setWorkspace(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{workspaces.length === 0 ? (
|
||||||
|
<option value="">—</option>
|
||||||
|
) : (
|
||||||
|
workspaces.map((w) => (
|
||||||
|
<option key={w.name} value={w.name}>
|
||||||
|
{w.name}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" htmlFor="set-provider">
|
||||||
|
Provider
|
||||||
|
</label>
|
||||||
|
{hasModels ? (
|
||||||
|
<select
|
||||||
|
id="set-provider"
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProvider(e.target.value);
|
||||||
|
setModel(""); // reset model when provider changes
|
||||||
|
}}
|
||||||
|
className="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— tutti —</option>
|
||||||
|
{providers.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id="set-provider"
|
||||||
|
type="text"
|
||||||
|
value={provider}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" htmlFor="set-model">
|
||||||
|
Modello
|
||||||
|
</label>
|
||||||
|
{hasModels ? (
|
||||||
|
<select
|
||||||
|
id="set-model"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">— default —</option>
|
||||||
|
{modelsForProvider.map((m) => (
|
||||||
|
<option key={`${m.provider}/${m.id}`} value={m.id}>
|
||||||
|
{m.provider}/{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
id="set-model"
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium mb-1" htmlFor="set-thinking">
|
||||||
|
Thinking
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="set-thinking"
|
||||||
|
value={thinking}
|
||||||
|
onChange={(e) => setThinking(e.target.value)}
|
||||||
|
className="w-full border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
{THINKING_LEVELS.map((lvl) => (
|
||||||
|
<option key={lvl} value={lvl}>
|
||||||
|
{lvl}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasModels && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||||
|
Annulla
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" disabled={busy}>
|
||||||
|
{busy ? "Salvataggio…" : "Salva"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user