- Add NewSessionDialog.test.tsx covering §9 graceful degradation:
empty models -> free-text input; non-empty models -> dropdown; plus a
createSession body-keys assertion
- Normalize model entries to {value,label} so object-shaped models
({provider,id}) render correctly in the dropdown (was assuming strings)
- NavSessions: invalidate the ["sessions"] query after a successful
resumeSession so the list/status refreshes immediately
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
// frontend/src/shell/NewSessionDialog.tsx
|
|
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "../components/ui/dialog";
|
|
import { Button } from "../components/ui/button";
|
|
import { createSession } from "../api/sessions";
|
|
import { listWorkspaces } from "../api/workspaces";
|
|
import { listModels } from "../api/models";
|
|
|
|
interface Props {
|
|
onCreated: (id: string) => void;
|
|
}
|
|
|
|
export function NewSessionDialog({ onCreated }: Props) {
|
|
const [open, setOpen] = useState(false);
|
|
const [question, setQuestion] = useState("");
|
|
const [workspace, setWorkspace] = useState("");
|
|
const [model, setModel] = useState("");
|
|
const [thinking, setThinking] = useState("");
|
|
const [provider, setProvider] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const { data: workspaces = [] } = useQuery({
|
|
queryKey: ["workspaces"],
|
|
queryFn: listWorkspaces,
|
|
enabled: open,
|
|
});
|
|
|
|
const { data: modelsData } = useQuery({
|
|
queryKey: ["models"],
|
|
queryFn: listModels,
|
|
enabled: open,
|
|
});
|
|
|
|
// Model entries may be plain strings or objects ({ id, provider, ... });
|
|
// normalize to { value, label } so the dropdown renders either shape.
|
|
const modelList: { value: string; label: string }[] = Array.isArray(
|
|
modelsData?.models,
|
|
)
|
|
? modelsData.models.map((m) => {
|
|
if (typeof m === "string") return { value: m, label: m };
|
|
const o = m as { id?: string; provider?: string };
|
|
const value = o.id ?? "";
|
|
const label = o.provider ? `${o.provider}/${value}` : value;
|
|
return { value, label };
|
|
})
|
|
: [];
|
|
|
|
// When workspace list loads, pre-select the first one if nothing chosen yet
|
|
const effectiveWorkspace =
|
|
workspace || (workspaces.length > 0 ? workspaces[0].name : "default");
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
if (!question.trim()) {
|
|
setError("La domanda non può essere vuota.");
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const { id } = await createSession({
|
|
workspace: effectiveWorkspace,
|
|
question: question.trim(),
|
|
...(model ? { model } : {}),
|
|
...(thinking ? { thinking } : {}),
|
|
...(provider ? { provider } : {}),
|
|
});
|
|
setOpen(false);
|
|
setQuestion("");
|
|
setModel("");
|
|
setThinking("");
|
|
setProvider("");
|
|
onCreated(id);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Errore nella creazione della sessione.");
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="w-full"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
Nuova domanda
|
|
</Button>
|
|
<DialogContent showCloseButton={false}>
|
|
<DialogHeader>
|
|
<DialogTitle>Nuova sessione</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1" htmlFor="nsd-question">
|
|
Domanda
|
|
</label>
|
|
<textarea
|
|
id="nsd-question"
|
|
value={question}
|
|
onChange={(e) => setQuestion(e.target.value)}
|
|
rows={3}
|
|
className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 resize-none"
|
|
placeholder="Inserisci la tua domanda…"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1" htmlFor="nsd-workspace">
|
|
Workspace
|
|
</label>
|
|
<select
|
|
id="nsd-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="default">default</option>
|
|
) : (
|
|
workspaces.map((w) => (
|
|
<option key={w.name} value={w.name}>
|
|
{w.name}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Model field — graceful degradation (§9): free text when models list is empty */}
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1" htmlFor="nsd-model">
|
|
Modello (opzionale)
|
|
</label>
|
|
{modelList.length > 0 ? (
|
|
<select
|
|
id="nsd-model"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
className="w-full border rounded px-2 py-1 text-sm"
|
|
>
|
|
<option value="">— default —</option>
|
|
{modelList.map((m) => (
|
|
<option key={m.value} value={m.value}>
|
|
{m.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
) : (
|
|
<input
|
|
id="nsd-model"
|
|
type="text"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder="es. claude-sonnet-4-5"
|
|
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="nsd-thinking">
|
|
Thinking (opzionale)
|
|
</label>
|
|
<input
|
|
id="nsd-thinking"
|
|
type="text"
|
|
value={thinking}
|
|
onChange={(e) => setThinking(e.target.value)}
|
|
placeholder="enabled / disabled"
|
|
className="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium mb-1" htmlFor="nsd-provider">
|
|
Provider (opzionale)
|
|
</label>
|
|
<input
|
|
id="nsd-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"
|
|
/>
|
|
</div>
|
|
|
|
{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 ? "Creazione…" : "Crea"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|