feat(frontend): English UI + 'Datamart Builder with Human In The Loop' tagline
The interface is now fully English regardless of the chat language (session titles stay in whatever language the user typed). Changed the right-rail subtitle to 'Datamart Builder with Human In The Loop'. Translated all chrome: new-question/settings dialogs, workflow phase strip (F1-F8), session rail, steer input, widgets (select/multiselect/freetext/fallback/reserved controls) and viewers (SQL, results, schema linking). Test matchers updated in lockstep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,7 +61,7 @@ export function AppShell() {
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[0.7rem] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Query in linguaggio naturale
|
||||
Datamart Builder with Human In The Loop
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AppShell() {
|
||||
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
<span className="text-[0.7rem] font-bold uppercase tracking-[0.12em] text-primary">
|
||||
Sessioni
|
||||
Sessions
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
||||
@@ -95,8 +95,9 @@ function EmptyState() {
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</span>
|
||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
||||
Interroga il tuo data warehouse in linguaggio naturale. Crea una nuova
|
||||
domanda dal pannello a destra per avviare una sessione guidata.
|
||||
Build datamarts from your data warehouse through a guided,
|
||||
human-in-the-loop workflow. Start a new question from the panel on the
|
||||
right to begin a session.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function NavSessions({ activeSessionId, onSessionSelect }: Props) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||||
Nessuna sessione. Inizia con <span className="font-medium text-foreground">Nuova domanda</span>.
|
||||
No sessions yet. Start with <span className="font-medium text-foreground">New question</span>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ function renderDialog() {
|
||||
|
||||
test("the form has only a question field (no workspace/model/provider/thinking)", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||
expect(await screen.findByLabelText(/domanda/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||
expect(await screen.findByLabelText(/question/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/workspace/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/modello/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/model/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/provider/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/thinking/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -36,9 +36,9 @@ test("submitting posts only { question } and calls onCreated", async () => {
|
||||
}),
|
||||
);
|
||||
const { onCreated } = renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||
await userEvent.type(await screen.findByLabelText(/domanda/i), "Quante vendite nel 2025?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||
await userEvent.type(await screen.findByLabelText(/question/i), "Quante vendite nel 2025?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({ question: "Quante vendite nel 2025?" }));
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalledWith("s1"));
|
||||
@@ -46,7 +46,7 @@ test("submitting posts only { question } and calls onCreated", async () => {
|
||||
|
||||
test("empty question shows a validation error and does not submit", async () => {
|
||||
renderDialog();
|
||||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/vuota/i);
|
||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(/empty/i);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ export function NewSessionDialog({ onCreated }: Props) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!question.trim()) {
|
||||
setError("La domanda non può essere vuota.");
|
||||
setError("The question cannot be empty.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@@ -33,7 +33,7 @@ export function NewSessionDialog({ onCreated }: Props) {
|
||||
setQuestion("");
|
||||
onCreated(id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Errore nella creazione della sessione.");
|
||||
setError(err instanceof Error ? err.message : "Failed to create the session.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -47,16 +47,16 @@ export function NewSessionDialog({ onCreated }: Props) {
|
||||
className="w-full"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Nuova domanda
|
||||
New question
|
||||
</Button>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuova sessione</DialogTitle>
|
||||
<DialogTitle>New session</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="nsd-question">
|
||||
Domanda
|
||||
Question
|
||||
</label>
|
||||
<textarea
|
||||
id="nsd-question"
|
||||
@@ -64,7 +64,7 @@ export function NewSessionDialog({ onCreated }: Props) {
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
placeholder="Inserisci la tua domanda…"
|
||||
placeholder="Enter your question…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -81,10 +81,10 @@ export function NewSessionDialog({ onCreated }: Props) {
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Annulla
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{busy ? "Creazione…" : "Crea"}
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -54,7 +54,7 @@ test("provider change filters the model list", async () => {
|
||||
|
||||
const provider = (await screen.findByLabelText(/provider/i)) as HTMLSelectElement;
|
||||
await userEvent.selectOptions(provider, "anthropic");
|
||||
const model = screen.getByLabelText(/modello/i) as HTMLSelectElement;
|
||||
const model = screen.getByLabelText(/model/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");
|
||||
@@ -65,14 +65,14 @@ test("Salva PUTs the selected settings", async () => {
|
||||
server.use(
|
||||
http.put("http://localhost:8787/settings", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json(body);
|
||||
return HttpResponse.json({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" });
|
||||
}),
|
||||
);
|
||||
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 userEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(body).toEqual({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }),
|
||||
|
||||
@@ -76,7 +76,7 @@ export function SettingsDialog() {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Errore nel salvataggio dei settings.");
|
||||
setError(err instanceof Error ? err.message : "Failed to save settings.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -133,7 +133,7 @@ export function SettingsDialog() {
|
||||
}}
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
>
|
||||
<option value="">— tutti —</option>
|
||||
<option value="">— all —</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
@@ -146,7 +146,7 @@ export function SettingsDialog() {
|
||||
type="text"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
placeholder="es. anthropic"
|
||||
placeholder="e.g. anthropic"
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
data-testid="provider-freetext"
|
||||
/>
|
||||
@@ -155,7 +155,7 @@ export function SettingsDialog() {
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="set-model">
|
||||
Modello
|
||||
Model
|
||||
</label>
|
||||
{hasModels ? (
|
||||
<select
|
||||
@@ -177,7 +177,7 @@ export function SettingsDialog() {
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="es. glm-5.2"
|
||||
placeholder="e.g. glm-5.2"
|
||||
className="w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus:border-primary/50 focus:ring-3 focus:ring-ring/15"
|
||||
data-testid="model-freetext"
|
||||
/>
|
||||
@@ -204,7 +204,7 @@ export function SettingsDialog() {
|
||||
|
||||
{!hasModels && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pi non ha restituito modelli (non in esecuzione o nessuna API key): inserimento libero.
|
||||
Pi returned no models (not running or no API key): free text entry.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
@@ -215,10 +215,10 @@ export function SettingsDialog() {
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||
Annulla
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{busy ? "Salvataggio…" : "Salva"}
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function SteerInput({ sessionId }: Props) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Steering libero (!)…"
|
||||
placeholder="Free steering (!)…"
|
||||
aria-label="Steering"
|
||||
className="flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
@@ -40,7 +40,7 @@ export function SteerInput({ sessionId }: Props) {
|
||||
size="icon-sm"
|
||||
onClick={submit}
|
||||
disabled={!text.trim()}
|
||||
aria-label="Invia"
|
||||
aria-label="Send"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
|
||||
@@ -19,8 +19,8 @@ export function WidgetHost({ sessionId }: { sessionId: string | null }) {
|
||||
level: "error",
|
||||
text:
|
||||
err instanceof Error
|
||||
? `Invio risposta fallito: ${err.message}`
|
||||
: "Invio risposta fallito.",
|
||||
? `Failed to send response: ${err.message}`
|
||||
: "Failed to send response.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
|
||||
const PHASES = [
|
||||
{ id: "F1", name: "Disambiguazione" },
|
||||
{ id: "F1", name: "Disambiguation" },
|
||||
{ id: "F2", name: "Schema linking" },
|
||||
{ id: "F3", name: "Esplorazione" },
|
||||
{ id: "F4", name: "Piano SQL" },
|
||||
{ id: "F5", name: "Generazione SQL" },
|
||||
{ id: "F6", name: "Validazione" },
|
||||
{ id: "F7", name: "Revisione" },
|
||||
{ id: "F8", name: "Risultati" },
|
||||
{ id: "F3", name: "Exploration" },
|
||||
{ id: "F4", name: "SQL plan" },
|
||||
{ id: "F5", name: "SQL generation" },
|
||||
{ id: "F6", name: "Validation" },
|
||||
{ id: "F7", name: "Review" },
|
||||
{ id: "F8", name: "Results" },
|
||||
];
|
||||
|
||||
export function WorkflowBar() {
|
||||
@@ -18,7 +18,7 @@ export function WorkflowBar() {
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Avanzamento workflow"
|
||||
aria-label="Workflow progress"
|
||||
className="flex items-center gap-0 overflow-x-auto py-0.5"
|
||||
>
|
||||
{PHASES.map((p, i) => {
|
||||
|
||||
@@ -31,12 +31,12 @@ test("F1: create session -> widget via SSE -> respond -> POST /response", async
|
||||
render(<App />);
|
||||
|
||||
// Open the "Nuova domanda" dialog
|
||||
await userEvent.click(screen.getByRole("button", { name: /nuova/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /new/i }));
|
||||
|
||||
// Fill in the question and submit
|
||||
const textarea = await screen.findByLabelText(/domanda/i);
|
||||
const textarea = await screen.findByLabelText(/question/i);
|
||||
await userEvent.type(textarea, "Quante vendite?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /^crea$/i }));
|
||||
await userEvent.click(screen.getByRole("button", { name: /^create$/i }));
|
||||
|
||||
// Wait for the session to be created and SSE to connect
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
|
||||
Reference in New Issue
Block a user