Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -1,7 +1,23 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
afterEach(() => act(() => setLocale("en")));
test("localizes repair recovery without changing the recorded choice or archive content", async () => {
const onRespond = vi.fn();
render(<ArchiveRepairWidget descriptor={{ id: "retry-it", widget: "archive-repair",
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={onRespond} />);
act(() => setLocale("it"));
expect(screen.getByRole("status")).toHaveTextContent("L'attivazione dell'indice è incompleta");
expect(screen.getByText("Before memory")).toBeVisible();
expect(screen.getByText("After evidence")).toBeVisible();
expect(screen.getByText("Conflicting order definitions")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Riprova la correzione selezionata" }));
expect(onRespond).toHaveBeenCalledWith({ id: "retry-it", choices: ["e"] });
});
const repair = { repair_id: "receipt", reason: "Conflicting order definitions", choice: null,
status: "proposed", saved: false, indexed: false, can_apply: true, options: [
{ id: "m", label: "Correct Memory", archive: "memory", target_id: "mem-1",
+23 -16
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
@@ -25,27 +26,35 @@ const fieldLabels: Record<string, string> = {
language: "Language", purposes: "Purposes", applies_to: "Applies to", payload: "Content",
provenance: "Source history", review_items: "Review items",
};
const nestedFieldLabels: Record<string, string> = {
...fieldLabels,
database: "Database", schema_name: "Schema", table: "Table", column: "Column",
target_id: "Target", meaning: "Meaning", rule: "Rule", text: "Text", source: "Source",
};
function Value({ value }: { value: unknown }) {
const { t: translate } = useI18n();
if (value == null) return null;
if (Array.isArray(value)) return <ul className="space-y-1">{value.map((item, index) =>
<li key={index}><Value value={item} /></li>)}</ul>;
if (typeof value === "object") return <dl className="space-y-1">{Object.entries(value).map(([key, item]) =>
<div key={key}><dt className="text-xs capitalize">{key.replaceAll("_", " ")}</dt>
<div key={key}><dt className="text-xs capitalize">{nestedFieldLabels[key] ? translate(nestedFieldLabels[key]) : key.replaceAll("_", " ")}</dt>
<dd className="pl-2"><Value value={item} /></dd></div>)}</dl>;
return <span>{String(value)}</span>;
}
function Content({ value }: { value: Record<string, unknown> }) {
const { t: translate } = useI18n();
return <dl className="min-w-0 space-y-2 text-sm">{Object.entries(fieldLabels).map(([key, label]) => {
const item = value[key];
if (item == null || item === "" || (Array.isArray(item) && item.length === 0)) return null;
return <div key={key}><dt className="font-medium">{label}</dt>
return <div key={key}><dt className="font-medium">{translate(label)}</dt>
<dd className="whitespace-pre-wrap break-words text-muted-foreground"><Value value={item} /></dd></div>;
})}</dl>;
}
export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const repair = descriptor.repair as Repair;
const [submitted, setSubmitted] = useState(false);
const send = (choice: string) => {
@@ -54,37 +63,35 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
onRespond({ id: descriptor.id, choices: [choice] });
};
const pending = repair.status === "applying" || repair.status === "pending_activation";
return <section aria-label="Archive conflict repair" className="space-y-4 rounded-xl border border-border bg-card p-5">
<div><h3 className="font-semibold">Resolve archive conflict</h3>
return <section aria-label={translate("Archive conflict repair")} className="space-y-4 rounded-xl border border-border bg-card p-5">
<div><h3 className="font-semibold">{translate("Resolve archive conflict")}</h3>
<p className="mt-1 whitespace-pre-wrap text-sm">{repair.reason}</p></div>
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ?? repair.status}</p>
<p role="status" className="rounded-md bg-muted p-3 text-sm">{statuses[repair.status] ? translate(statuses[repair.status]) : repair.status}</p>
{Boolean(descriptor.error) && <p role="alert" className="text-sm text-destructive">{String(descriptor.error)}</p>}
{!repair.can_apply && <p className="text-sm text-muted-foreground">An administrator must approve shared archive corrections.
You can reject the proposals or continue reviewing this question.</p>}
{!repair.can_apply && <p className="text-sm text-muted-foreground">{translate("An administrator must approve shared archive corrections. You can reject the proposals or continue reviewing this question.")}</p>}
<fieldset disabled={submitted} className="min-w-0 space-y-4">
{repair.options.map(option => <article key={option.id} className="space-y-3 border-t border-border pt-4">
<div><h4 className="text-sm font-semibold">{option.label}</h4>
<p className="text-xs text-muted-foreground">{option.archive === "memory" ? "Memory" : "Evidence"} · {option.target_id}
{repair.choice === option.id ? " · Selected" : ""}</p></div>
<p className="text-xs text-muted-foreground">{option.archive === "memory" ? translate("Memory") : translate("Evidence")} · {option.target_id}
{repair.choice === option.id && <> · {translate("Selected")}</>}</p></div>
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<div className="min-w-0 rounded-md bg-muted/50 p-3"><p className="mb-2 text-xs font-semibold">Before this proposal</p>
<div className="min-w-0 rounded-md bg-muted/50 p-3"><p className="mb-2 text-xs font-semibold">{translate("Before this proposal")}</p>
<Content value={option.before} /></div>
<div className="min-w-0 rounded-md border border-border p-3"><p className="mb-2 text-xs font-semibold">Proposed result</p>
<div className="min-w-0 rounded-md border border-border p-3"><p className="mb-2 text-xs font-semibold">{translate("Proposed result")}</p>
<Content value={option.content} /></div>
</div>
{repair.can_apply && !repair.choice && <button type="button" onClick={() => send(option.id)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
Apply this correction to {option.archive === "memory" ? "Memory" : "Evidence"}
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
</button>}
</article>)}
<div className="flex flex-wrap gap-3 border-t border-border pt-4">
{!repair.choice && <button type="button" onClick={() => send("reject")} className="rounded-md border border-border px-3 py-2 text-sm">
None are adequate — reformulate
</button>}
{translate("None are adequate — reformulate")}</button>}
{pending && repair.can_apply && repair.choice && <button type="button" onClick={() => send(repair.choice!)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">Retry selected correction</button>}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">{translate("Retry selected correction")}</button>}
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? "Continue with repair pending" : "Continue question review"}</button>}
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? translate("Continue with repair pending") : translate("Continue question review")}</button>}
</div>
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => {
if (!submitted) { setSubmitted(true); onRespond({ id: descriptor.id, control, text }); }
+4 -2
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
@@ -14,6 +15,7 @@ import { LinkageHost } from "./LinkageHost";
* out is an action or a reserved control, both of which call onRespond.
*/
export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetProps) {
const { t: translate } = useI18n();
const [pendingLinkage, setPendingLinkage] = useState<{
parentResponse: UiResponse;
childDescriptor: WidgetDescriptor;
@@ -33,14 +35,14 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
className="grid h-[70vh] w-[min(52rem,calc(var(--app-area-width,100vw)-3rem))] max-w-none grid-rows-[auto_1fr_auto] gap-3.5 sm:max-w-none"
>
<DialogTitle className="text-lg leading-snug">
{descriptor.title ?? "Artifact review"}
{descriptor.title ?? translate("Artifact review")}
</DialogTitle>
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-5">
{descriptor.artifact ? (
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
) : (
<p className="text-sm text-muted-foreground">No artifact available.</p>
<p className="text-sm text-muted-foreground">{translate("No artifact available.")}</p>
)}
</div>
@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FallbackWidget } from "./FallbackWidget";
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test("protects manual response drafts until cleared or unmounted, including after sending", async () => {
const onRespond = vi.fn();
const { unmount } = render(<FallbackWidget descriptor={{ id: "unknown", widget: "unknown" }} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Manual answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.clear(screen.getByRole("textbox"));
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Correction");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onRespond).toHaveBeenCalledWith({ id: "unknown", control: "freetext", text: "Correction" });
expect(reloadIsBlocked()).toBe(true);
await userEvent.type(screen.getByRole("textbox"), " updated");
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
+9 -3
View File
@@ -1,14 +1,20 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
import type { WidgetProps } from "./types";
export function FallbackWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const [text, setText] = useState("");
useUnsavedDraft(text);
return (
<div className="rounded-xl border border-border/70 bg-card p-3 shadow-sm space-y-2">
<p className="text-sm text-amber-600">Unsupported widget (kind: {descriptor.widget}) — respond manually</p>
<p className="text-sm text-amber-600">{translate("Unsupported widget (kind: {kind}) — respond manually", { kind: descriptor.widget })}</p>
<pre className="text-xs font-mono overflow-auto max-h-48 rounded-lg bg-muted p-2">{JSON.stringify(descriptor, null, 2)}</pre>
<textarea className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => onRespond({ id: descriptor.id, control: "freetext", text })}>Send</button>
<textarea aria-label={translate("Manual response")} className="w-full rounded-lg border border-input bg-card p-2 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20" value={text} onChange={(e) => setText(e.target.value)} />
<button className="rounded-md border border-border/70 px-2 py-1 text-sm shadow-xs transition-colors hover:bg-accent" onClick={() => {
onRespond({ id: descriptor.id, control: "freetext", text });
}}>{translate("Send")}</button>
</div>
);
}
@@ -1,6 +1,29 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FreetextWidget } from "./FreetextWidget";
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test("protects an answer draft until cleared or unmounted, including after sending", async () => {
const onRespond = vi.fn();
const { unmount } = render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Draft answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.clear(screen.getByRole("textbox"));
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Answer{Enter}");
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "Answer" });
expect(reloadIsBlocked()).toBe(true);
await userEvent.type(screen.getByRole("textbox"), " updated");
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("submits typed text", async () => {
const onRespond = vi.fn();
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
+10 -4
View File
@@ -1,10 +1,16 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
import type { WidgetProps } from "./types";
export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const [text, setText] = useState("");
useUnsavedDraft(text);
function submit() {
if (text.trim()) onRespond({ id: descriptor.id, kind: "freetext", text });
if (text.trim()) {
onRespond({ id: descriptor.id, kind: "freetext", text });
}
}
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
@@ -19,14 +25,14 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); } }}
placeholder="Type your answer…"
placeholder={translate("Type your answer…")}
aria-label={translate("Your answer")}
/>
<button
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={submit}
>
Send
</button>
{translate("Send")}</button>
</div>
);
}
+3 -1
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import type { ReactElement } from "react";
import { useQuery } from "@tanstack/react-query";
import { getSessionDocuments } from "../api/sessions";
@@ -46,11 +47,12 @@ function ResolvedDoc({
filename: string;
fallback: ReactElement;
}): ReactElement {
const { t: translate } = useI18n();
const { data: docs, isLoading, isError } = useQuery<SessionDocument[]>({
queryKey: ["session-documents", sessionId],
queryFn: () => getSessionDocuments(sessionId),
});
if (isLoading) return <p className="text-sm text-muted-foreground">Loading…</p>;
if (isLoading) return <p className="text-sm text-muted-foreground">{translate("Loading…")}</p>;
const doc = docs?.find((d) => d.key === docKey);
if (isError || !doc || doc.format !== "markdown") return fallback;
return (
+5 -6
View File
@@ -1,8 +1,10 @@
import { useI18n } from "../i18n";
import { Link2 } from "lucide-react";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const options = descriptor.options ?? [];
return (
@@ -14,9 +16,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
</p>
)}
<p className="text-sm leading-relaxed text-muted-foreground">
These joins are required by the selected tables and are shown for review.
To request a correction, use Other — specify.
</p>
{translate("These joins are required by the selected tables and are shown for review. To request a correction, use Other — specify.")}</p>
</div>
<div className="space-y-2.5">
@@ -46,8 +46,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
{option.rationale && (
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Rationale
</span>
{translate("Rationale")}</span>
<p className="text-sm leading-relaxed text-muted-foreground">
{option.rationale}
</p>
@@ -69,7 +68,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
})
}
>
{descriptor.confirm_label ?? "Continue"}
{descriptor.confirm_label ?? translate("Continue")}
</button>
<ReservedControls
@@ -1,7 +1,10 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryReviewWidget } from "./MemoryReviewWidget";
import type { MemoryInput } from "../api/memory";
import { setLocale } from "../i18n";
afterEach(() => act(() => setLocale("en")));
const card: MemoryInput = { family: "domain_clarification", subject: "Active patients",
detail: "Active means the flag is true", scope: "clinical", rationale: "Approved definition",
@@ -13,6 +16,53 @@ const descriptor = { id: "review", widget: "memory-review", summary: { summary_i
reason: "Approved solution", source_seqs: [7], before: null, target_id: null },
] } };
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test.each(["subject", "scope", "detail", "rationale", "question", "concepts"])(
"protects unsaved Memory %s edits until the submitted widget unmounts",
async (field) => {
const onRespond = vi.fn();
const { unmount } = render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByLabelText(`domain ${field}`), " revised");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
},
);
test("protects Memory selection, dependency and link drafts, including invalid edits, until reverted or unmounted", async () => {
const { unmount } = render(<MemoryReviewWidget descriptor={descriptor} onRespond={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Select all" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]);
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
expect(reloadIsBlocked()).toBe(true); // Required database is still empty.
await userEvent.click(screen.getByRole("button", { name: "Remove dependency" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Remove link" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByLabelText("domain detail"), " correction");
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
@@ -21,7 +71,7 @@ test("sends only selected cards with reviewer edits and physical dependencies",
await userEvent.click(screen.getByRole("checkbox", { name: /Count active patients/ }));
await userEvent.click(screen.getAllByRole("button", { name: "Add dependency" })[0]!);
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
await userEvent.type(screen.getByLabelText("domain dependency 1 schema_name"), "clinical");
await userEvent.type(screen.getByLabelText("domain dependency 1 schema"), "clinical");
await userEvent.type(screen.getByLabelText("domain dependency 1 table"), "patients");
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
expect(onRespond).toHaveBeenCalledTimes(1);
@@ -51,3 +101,28 @@ test("links selected proposals and keeps approved SQL read only", async () => {
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
});
test("switches review chrome to Italian without changing edited cards, SQL or response identifiers", async () => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.clear(screen.getByLabelText("domain detail"));
await userEvent.type(screen.getByLabelText("domain detail"), "Reviewer correction {{scope}");
act(() => setLocale("it"));
expect(screen.getByRole("form", { name: "Riepilogo delle memorie" })).toBeVisible();
expect(screen.getByLabelText("domain dettaglio")).toHaveValue("Reviewer correction {scope}");
expect(screen.getByText("2 di 2 selezionate")).toBeVisible();
expect(screen.getByText("SELECT COUNT(*) FROM patients WHERE active")).toBeVisible();
expect(screen.getByText("Approved clarification")).toBeVisible();
await userEvent.click(screen.getAllByRole("button", { name: "Aggiungi dipendenza" })[0]);
await userEvent.type(screen.getByLabelText("domain dipendenza 1 database"), "warehouse");
await userEvent.type(screen.getByLabelText("domain dipendenza 1 tabella"), "patients");
await userEvent.click(screen.getByRole("button", { name: "Salva le selezionate e termina" }));
const response = onRespond.mock.calls[0][0];
expect(response).toMatchObject({ id: "review", kind: "memory-review" });
expect(JSON.parse(response.text).items[0]).toEqual({ id: "domain", card: { ...card,
detail: "Reviewer correction {scope}",
dependencies: [{ database: "warehouse", schema_name: "", table: "patients", column: "" }],
} });
});
+33 -29
View File
@@ -1,4 +1,6 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
import type { MemoryCard, MemoryInput } from "../api/memory";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
@@ -10,20 +12,24 @@ interface Proposal {
interface Summary { summary_id: string; items: Proposal[] }
const labels = { domain_clarification: "Domain clarification", sql_rule: "SQL rule",
solved_question: "Solved question", explained_error: "Explained error" };
const fieldLabels = { subject: "subject", detail: "detail", scope: "scope", rationale: "rationale",
question: "question", sql: "SQL", database: "database", schema_name: "schema", table: "table", column: "column" };
const inputStyle = "min-w-0 w-full rounded-md border border-border bg-background p-2 text-sm text-foreground";
export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const summary = descriptor.summary as Summary;
const [items, setItems] = useState(summary.items);
const [selected, setSelected] = useState(new Set(summary.items.map(p => p.id)));
const [submitted, setSubmitted] = useState(false);
useUnsavedDraft(JSON.stringify({ items, selected: [...selected].sort() }));
const patch = (id: string, changes: Partial<MemoryInput>) => setItems(current => current.map(
item => item.id === id ? { ...item, card: { ...item.card, ...changes } } : item));
const toggle = (id: string) => setSelected(current => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
return <form aria-label="Memory summary" className="space-y-5 rounded-xl border border-border bg-card p-5"
return <form aria-label={translate("Memory summary")} className="space-y-5 rounded-xl border border-border bg-card p-5"
onSubmit={event => {
event.preventDefault();
if (submitted) return;
@@ -35,90 +41,88 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
}) });
}}>
<div>
<h3 className="font-semibold text-foreground">Memory for future questions</h3>
<p className="mt-1 text-sm text-muted-foreground">Review the scope and wording, then choose what to save.
Only selected cards and their links will enter the shared archive.</p>
<h3 className="font-semibold text-foreground">{translate("Memory for future questions")}</h3>
<p className="mt-1 text-sm text-muted-foreground">{translate("Review the scope and wording, then choose what to save. Only selected cards and their links will enter the shared archive.")}</p>
</div>
<fieldset disabled={submitted} className="min-w-0 space-y-4">
<button type="button" className="text-sm font-medium text-primary" onClick={() => setSelected(
selected.size ? new Set() : new Set(items.map(item => item.id)))}>
{selected.size ? "Deselect all" : "Select all"}
{selected.size ? translate("Deselect all") : translate("Select all")}
</button>
{items.map(item => <section key={item.id} className="space-y-3 border-t border-border pt-4">
<label className="flex items-start gap-3 text-sm font-medium">
<input type="checkbox" className="mt-1 size-4" checked={selected.has(item.id)} onChange={() => toggle(item.id)} />
<span>{item.card.subject}<span className="block text-xs font-normal text-muted-foreground">
{item.target_id ? "Update existing card" : "New card"} · {labels[item.card.family]}
{item.target_id ? translate("Update existing card") : translate("New card")} · {translate(labels[item.card.family])}
</span></span>
</label>
<p className="text-sm text-muted-foreground">{item.reason}</p>
<p className="text-xs text-muted-foreground">Approved decisions: {item.source_seqs.map(seq => `#${seq}`).join(", ")}</p>
<p className="text-xs text-muted-foreground">{translate("Approved decisions: {decisions}", { decisions: item.source_seqs.map(seq => `#${seq}`).join(", ") })}</p>
{item.before && <details className="rounded-md bg-muted p-3 text-sm">
<summary className="cursor-pointer font-medium">Current card being replaced</summary>
<summary className="cursor-pointer font-medium">{translate("Current card being replaced")}</summary>
<dl className="mt-2 space-y-2">
{(["subject", "detail", "scope", "rationale", "question", "sql"] as const).map(key =>
<div key={key}><dt className="font-medium capitalize">{key}</dt>
<div key={key}><dt className="font-medium capitalize">{translate(fieldLabels[key])}</dt>
<dd className="whitespace-pre-wrap break-words">{item.before![key] || "—"}</dd></div>)}
</dl>
</details>}
<fieldset disabled={!selected.has(item.id)} className="min-w-0 space-y-3 disabled:opacity-60">
{(["subject", "scope", "detail", "rationale", "question"] as const).map(key => <label key={key} className="block text-xs font-medium capitalize">
{key}<textarea aria-label={`${item.id} ${key}`} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
{translate(fieldLabels[key])}<textarea aria-label={translate("{id} {field}", { id: item.id, field: translate(fieldLabels[key]) })} className={`${inputStyle} mt-1`} rows={key === "detail" ? 3 : 2}
required={key === "subject" || key === "scope" || (key === "rationale" && item.card.family === "explained_error")}
value={item.card[key]} onChange={event => patch(item.id, { [key]: event.target.value })} />
</label>)}
{item.card.family === "solved_question" && <div className="text-sm">
<p className="font-medium">Approved SQL</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
<p className="mt-1 text-xs text-muted-foreground">To change the solution, return to SQL review.</p>
<p className="font-medium">{translate("Approved SQL")}</p><pre className="mt-1 overflow-x-auto rounded-md bg-muted p-3 text-xs">{item.card.sql}</pre>
<p className="mt-1 text-xs text-muted-foreground">{translate("To change the solution, return to SQL review.")}</p>
</div>}
<label className="block text-xs font-medium">Concepts, separated by commas
<input aria-label={`${item.id} concepts`} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
<label className="block text-xs font-medium">{translate("Concepts, separated by commas")}<input aria-label={translate("{id} concepts", { id: item.id })} className={`${inputStyle} mt-1`} value={item.card.concepts.join(", ")}
onChange={event => patch(item.id, { concepts: event.target.value.split(",").map(v => v.trim()) })} />
</label>
<div className="space-y-2"><p className="text-xs font-medium">Physical dependencies</p>
<div className="space-y-2"><p className="text-xs font-medium">{translate("Physical dependencies")}</p>
{item.card.dependencies.map((dependency, index) => <div key={index} className="grid gap-2 sm:grid-cols-2">
{(["database", "schema_name", "table", "column"] as const).map(key => <input key={key}
className={inputStyle} aria-label={`${item.id} dependency ${index+1} ${key}`} placeholder={key}
className={inputStyle} aria-label={translate("{id} dependency {number} {field}", { id: item.id, number: index + 1, field: translate(fieldLabels[key]) })} placeholder={translate(fieldLabels[key])}
required={key === "database"} value={dependency[key]} onChange={event => patch(item.id, {
dependencies: item.card.dependencies.map((d, i) => i === index ? { ...d, [key]: event.target.value } : d),
})} />)}
<button type="button" className="text-left text-xs text-muted-foreground" onClick={() => patch(item.id, {
dependencies: item.card.dependencies.filter((_, i) => i !== index),
})}>Remove dependency</button>
})}>{translate("Remove dependency")}</button>
</div>)}
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
dependencies: [...item.card.dependencies, { database: "", schema_name: "", table: "", column: "" }],
})}>Add dependency</button>
})}>{translate("Add dependency")}</button>
</div>
<div className="space-y-2"><p className="text-xs font-medium">Links to other cards</p>
<div className="space-y-2"><p className="text-xs font-medium">{translate("Links to other cards")}</p>
{item.card.links.map((link, index) => <div key={index} className="space-y-2 rounded-md border border-border p-2">
<input aria-label={`${item.id} link ${index+1} target`} className={inputStyle} required
value={link.target_id} placeholder="Card ID or proposal reference" onChange={event => patch(item.id, {
<input aria-label={translate("{id} link {number} target", { id: item.id, number: index + 1 })} className={inputStyle} required
value={link.target_id} placeholder={translate("Card ID or proposal reference")} onChange={event => patch(item.id, {
links: item.card.links.map((l, i) => i === index ? { ...l, target_id: event.target.value } : l),
})} />
<input aria-label={`${item.id} link ${index+1} meaning`} className={inputStyle} required
value={link.meaning} placeholder="Why these cards are linked" onChange={event => patch(item.id, {
<input aria-label={translate("{id} link {number} meaning", { id: item.id, number: index + 1 })} className={inputStyle} required
value={link.meaning} placeholder={translate("Why these cards are linked")} onChange={event => patch(item.id, {
links: item.card.links.map((l, i) => i === index ? { ...l, meaning: event.target.value } : l),
})} />
<button type="button" className="text-xs text-muted-foreground" onClick={() => patch(item.id, {
links: item.card.links.filter((_, i) => i !== index),
})}>Remove link</button>
})}>{translate("Remove link")}</button>
</div>)}
<select aria-label={`${item.id} add link`} className={inputStyle} value="" onChange={event => {
<select aria-label={translate("{id} add link", { id: item.id })} className={inputStyle} value="" onChange={event => {
if (event.target.value) patch(item.id, { links: [...item.card.links, { target_id: event.target.value, meaning: "" }] });
}}><option value="">Link to another proposed card…</option>
}}><option value="">{translate("Link to another proposed card…")}</option>
{items.filter(p => p.id !== item.id).map(p => <option key={p.id} value={`proposal:${p.id}`}>{p.card.subject}</option>)}
</select>
<button type="button" className="text-xs text-primary" onClick={() => patch(item.id, {
links: [...item.card.links, { target_id: "", meaning: "" }],
})}>Link to an existing card</button>
})}>{translate("Link to an existing card")}</button>
</div>
</fieldset>
</section>)}
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border pt-4">
<span className="text-sm text-muted-foreground">{selected.size} of {items.length} selected</span>
<span className="text-sm text-muted-foreground">{translate("{selected} of {total} selected", { selected: selected.size, total: items.length })}</span>
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
{submitted ? "Saving…" : selected.size ? "Save selected and finish" : "Finish without saving Memory"}
{submitted ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
</button>
</div>
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
@@ -1,7 +1,52 @@
import { render, screen } from "@testing-library/react";
import { act, render, screen } from "@testing-library/react";
import { setLocale } from "../i18n";
import userEvent from "@testing-library/user-event";
import { MultiselectWidget } from "./MultiselectWidget";
afterEach(() => act(() => setLocale("en")));
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test("protects changed choices until reverted or unmounted, including after confirming", async () => {
const onRespond = vi.fn();
const { unmount } = render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", options: [
{ id: "a", label: "Alpha", selected: true }, { id: "b", label: "Beta", selected: true },
] }} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
expect(reloadIsBlocked()).toBe(false); // Restoring a set in a different order is clean.
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: [] });
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "Beta" }));
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("keeps active choices and model labels unchanged across a locale switch", async () => {
const onRespond = vi.fn();
render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", options: [
{ id: "a", label: "Choose a workspace", detail: "SELECT id FROM patients", selected: true },
{ id: "b", label: "Model-defined choice" },
] }} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("checkbox", { name: "Model-defined choice" }));
act(() => setLocale("it"));
expect(screen.getByRole("button", { name: "Deseleziona tutto" })).toBeVisible();
expect(screen.getByRole("checkbox", { name: /Choose a workspace/ })).toBeChecked();
expect(screen.getByText("SELECT id FROM patients")).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Conferma" }));
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: ["a", "b"] });
});
test("confirms the checked ids including initially selected", async () => {
const onRespond = vi.fn();
render(
+9 -7
View File
@@ -1,13 +1,17 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const options = descriptor.options ?? [];
const initialChecked = new Set(options.filter((o) => o.selected).map((o) => o.id));
const [checked, setChecked] = useState<Set<string>>(initialChecked);
useUnsavedDraft(JSON.stringify([...checked].sort()));
const allChecked = options.length > 0 && options.every((o) => checked.has(o.id));
@@ -38,10 +42,10 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
setChecked(allChecked ? new Set() : new Set(options.map((o) => o.id)))
}
>
{allChecked ? "Deselect all" : "Select all"}
{allChecked ? translate("Deselect all") : translate("Select all")}
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{checked.size} / {options.length} {descriptor.selection_label ?? "selected"}
{checked.size} / {options.length} {descriptor.selection_label ?? translate("selected")}
</span>
</div>
<div className="flex flex-col gap-1 pt-2">
@@ -68,8 +72,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{o.rationale && (
<div className="mt-3 border-l-2 border-border/70 pl-3 text-xs text-foreground/80">
<div className="mb-1 font-semibold uppercase tracking-wide text-muted-foreground">
Reason
</div>
{translate("Reason")}</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{o.rationale}</ReactMarkdown>
</div>
@@ -78,8 +81,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{typeof o.meta?.question_context === "string" && o.meta.question_context && (
<div className="mt-3 border-l-2 border-border/50 pl-3 text-xs text-muted-foreground">
<div className="mb-1 font-semibold uppercase tracking-wide">
Context question
</div>
{translate("Context question")}</div>
<div className="thot-prose leading-relaxed [&>p:first-child]:mt-0 [&>p:last-child]:mb-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{o.meta.question_context}
@@ -98,7 +100,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
{descriptor.confirm_label ?? "Confirm"}
{descriptor.confirm_label ?? translate("Confirm")}
</button>
</div>
<ReservedControls
@@ -35,3 +35,38 @@ test("Send stays disabled until non-whitespace text is entered", async () => {
await userEvent.type(screen.getByRole("textbox"), "ok");
expect(send).not.toBeDisabled();
});
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test.each(["button", "Enter"])("protects Other drafts through %s submission until cleared or unmounted", async (method) => {
const onControl = vi.fn();
const { unmount } = render(<ReservedControls reserved={["back", "exit", "other"]} onControl={onControl} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
expect(reloadIsBlocked()).toBe(false);
const input = screen.getByRole("textbox", { name: "Your alternative" });
await userEvent.type(input, "Alternative");
expect(reloadIsBlocked()).toBe(true);
await userEvent.clear(input);
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(input, "Correction");
if (method === "Enter") await userEvent.keyboard("{Enter}");
else await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(onControl).toHaveBeenCalledWith("freetext", "Correction");
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("removes an unsubmitted Other draft warning on unmount", async () => {
const { unmount } = render(<ReservedControls reserved={["other"]} onControl={vi.fn()} />);
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
await userEvent.type(screen.getByRole("textbox"), "Correction");
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
+16 -6
View File
@@ -1,4 +1,6 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
const LABELS: Record<string, string> = { back: "Go back", exit: "Exit", other: "Other — specify" };
@@ -9,19 +11,26 @@ export function ReservedControls({
reserved?: string[];
onControl: (c: string, text?: string) => void;
}) {
const { t: translate } = useI18n();
const [otherOpen, setOtherOpen] = useState(false);
const [text, setText] = useState("");
useUnsavedDraft(text, otherOpen && Boolean(reserved?.length));
function submit() {
if (!text.trim()) return;
onControl("freetext", text);
}
if (!reserved?.length) return null;
return (
<div className="flex flex-col gap-2 border-t border-border/60 pt-3">
<div className="flex flex-wrap gap-2">
{reserved.map((c) => (
<button
type="button"
key={c}
className="rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => (c === "other" ? setOtherOpen(true) : onControl(c))}
>
{LABELS[c] ?? c}
{LABELS[c] ? translate(LABELS[c]) : c}
</button>
))}
</div>
@@ -32,16 +41,17 @@ export function ReservedControls({
className="thot-awaiting-input min-h-20 w-full resize-y rounded-lg border p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow]"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && text.trim()) { e.preventDefault(); onControl("freetext", text); } }}
placeholder="Describe your alternative…"
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && text.trim()) { e.preventDefault(); submit(); } }}
placeholder={translate("Describe your alternative…")}
aria-label={translate("Your alternative")}
/>
<button
type="button"
className="self-start rounded-md bg-primary px-3 py-1.5 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={!text.trim()}
onClick={() => onControl("freetext", text)}
onClick={submit}
>
Send
</button>
{translate("Send")}</button>
</div>
)}
</div>
+5 -3
View File
@@ -1,3 +1,4 @@
import { useI18n } from "../i18n";
import { useMemo, useState, type ReactElement } from "react";
import type { SchemaTable } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
@@ -17,6 +18,7 @@ export function SchemaColumnsDialog({
onToggle: (column: string) => void;
onClose: () => void;
}): ReactElement {
const { t: translate } = useI18n();
const [query, setQuery] = useState("");
// Suggested-first, then catalog order. Sorting on the descriptor's `suggested`
@@ -47,8 +49,8 @@ export function SchemaColumnsDialog({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="Filter columns"
placeholder="Filter columns…"
aria-label={translate("Filter columns")}
placeholder={translate("Filter columns…")}
className="min-w-0 flex-1 rounded-md border border-input bg-card px-2.5 py-1.5 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus:border-primary/50 focus:ring-2 focus:ring-ring/20"
/>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
@@ -85,7 +87,7 @@ export function SchemaColumnsDialog({
);
})}
{displayed.length === 0 && (
<li className="py-3 text-sm text-muted-foreground">No columns match the filter.</li>
<li className="py-3 text-sm text-muted-foreground">{translate("No columns match the filter.")}</li>
)}
</ul>
</div>
@@ -33,6 +33,38 @@ const descriptor: WidgetDescriptor = {
reserved: ["back", "exit", "other"],
};
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
test("protects changed tables and columns until reverted or unmounted, including after confirming", async () => {
const onRespond = vi.fn();
const { unmount } = render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "Columns 1/2" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
await userEvent.click(screen.getByRole("button", { name: /close/i }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Confirm" }));
expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({ kind: "schema-linking" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("starts include rows checked and exclude rows unchecked even for legacy payloads", () => {
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={vi.fn()} />);
@@ -1,10 +1,13 @@
import { useI18n } from "../i18n";
import { useState } from "react";
import { useUnsavedDraft } from "../hooks/useUnsavedDraft";
import type { WidgetProps } from "./types";
import type { SchemaTable } from "../api/types";
import { SchemaColumnsDialog } from "./SchemaColumnsDialog";
import { ReservedControls } from "./ReservedControls";
export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const tables: SchemaTable[] = descriptor.tables ?? [];
const [enacted, setEnacted] = useState<Set<string>>(
() => new Set(tables.filter((t) => t.kind === "promote" && t.recommended).map((t) => t.id))
@@ -20,6 +23,10 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
return m;
});
const [openId, setOpenId] = useState<string | null>(null);
useUnsavedDraft(JSON.stringify({
enacted: [...enacted].sort(),
selected: [...selected].map(([id, columns]) => [id, [...columns].sort()]),
}));
function toggleEnact(id: string) {
setEnacted((prev) => {
@@ -81,7 +88,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-foreground">{t.name}</span>
<span className="thot-label">{t.kind === "promote" ? "Promote" : "Exclude"}</span>
<span className="thot-label">{t.kind === "promote" ? translate("Promote") : translate("Exclude")}</span>
</div>
{t.description && <p className="mt-0.5 text-sm text-muted-foreground">{t.description}</p>}
{t.rationale && <p className="mt-0.5 text-sm text-foreground/80">{t.rationale}</p>}
@@ -90,7 +97,9 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
className="shrink-0 self-center rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setOpenId(t.id)}
>
{t.kind === "promote" ? `Columns ${count}/${t.columns.length}` : `Columns ${t.columns.length}`}
{t.kind === "promote"
? translate("Columns {selected}/{total}", { selected: count, total: t.columns.length })
: translate("Columns {total}", { total: t.columns.length })}
</button>
</div>
</div>
@@ -114,8 +123,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
onClick={confirm}
>
Confirm
</button>
{translate("Confirm")}</button>
</div>
<ReservedControls
+3 -2
View File
@@ -1,8 +1,10 @@
import { useI18n } from "../i18n";
import type { WidgetProps } from "./types";
import { ReservedControls } from "./ReservedControls";
import { IntroBody } from "./IntroBody";
export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{(descriptor.title || descriptor.intro) && (
@@ -25,8 +27,7 @@ export function SelectWidget({ descriptor, onRespond }: WidgetProps) {
<span>{o.label}</span>
{o.recommended && (
<span className="shrink-0 rounded-md bg-[oklch(var(--success)/0.16)] px-2 py-0.5 text-[0.65rem] font-medium text-[oklch(0.5_0.13_165)]">
Recommended
</span>
{translate("Recommended")}</span>
)}
</button>
))}
@@ -0,0 +1,60 @@
import type { ComponentType } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { WidgetDescriptor } from "../api/types";
import type { WidgetProps } from "./types";
import { SelectWidget } from "./SelectWidget";
import { MultiselectWidget } from "./MultiselectWidget";
import { JoinReviewWidget } from "./JoinReviewWidget";
import { ArtifactGateWidget } from "./ArtifactGateWidget";
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
import { MemoryReviewWidget } from "./MemoryReviewWidget";
import { ArchiveRepairWidget } from "./ArchiveRepairWidget";
function reloadIsBlocked() {
const event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}
const cases: [string, ComponentType<WidgetProps>, Partial<WidgetDescriptor>, string][] = [
["select", SelectWidget, { options: [{ id: "a", label: "Accept" }] }, "Accept"],
["multiselect", MultiselectWidget, { options: [{ id: "a", label: "Alpha" }] }, "Confirm"],
["join-review", JoinReviewWidget, {}, "Continue"],
["artifact-gate", ArtifactGateWidget, { options: [{ id: "a", label: "Accept" }] }, "Accept"],
["schema-linking", SchemaLinkingGateWidget, {}, "Confirm"],
["memory-review", MemoryReviewWidget, { summary: { summary_id: "summary", items: [] } }, "Finish without saving Memory"],
["archive-repair", ArchiveRepairWidget, { repair: {
repair_id: "repair", reason: "Conflict", options: [], choice: null,
status: "proposed", saved: false, indexed: false, can_apply: true,
} }, "None are adequate — reformulate"],
];
test.each(cases)("%s protects Other drafts through submission until the host unmounts the widget", async (widget, Widget, extra, action) => {
const onRespond = vi.fn();
const { unmount } = render(<Widget descriptor={{ id: "review", widget, reserved: ["other"], ...extra }} onRespond={onRespond} />);
expect(reloadIsBlocked()).toBe(false);
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
expect(onRespond).not.toHaveBeenCalled(); // Opening Other inside a form must not submit it.
await userEvent.type(screen.getByRole("textbox", { name: "Your alternative" }), "Alternative draft");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: action }));
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("submitting Other keeps changed main choices protected until unmount", async () => {
const onRespond = vi.fn();
const { unmount } = render(<MultiselectWidget descriptor={{ id: "review", widget: "multiselect", reserved: ["other"],
options: [{ id: "a", label: "Alpha" }],
}} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("checkbox", { name: "Alpha" }));
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
await userEvent.type(screen.getByRole("textbox"), "Correction{Enter}");
expect(onRespond).toHaveBeenCalledWith({ id: "review", control: "freetext", text: "Correction" });
expect(reloadIsBlocked()).toBe(true);
unmount();
expect(reloadIsBlocked()).toBe(false);
});
+16
View File
@@ -0,0 +1,16 @@
import type { WidgetDescriptor } from "../api/types";
/** Translate only explicit deterministic chrome, never arbitrary model text or artifacts. */
export function localizeWidget(descriptor: WidgetDescriptor, t: (message: string) => string): WidgetDescriptor {
const localized = { ...descriptor };
for (const field of ["title", "confirm_label", "selection_label", "prompt", "error"] as const) {
const message = descriptor[`${field}_i18n`];
if (typeof message === "string") localized[field] = t(message);
}
if (descriptor.options) localized.options = descriptor.options.map((option) => ({
...option,
label: option.label_i18n ? t(option.label_i18n) : option.label,
...(option.opens ? { opens: localizeWidget(option.opens, t) } : {}),
}));
return localized;
}