fix(ui): expand session dialogs and repeat confirmation actions
Publish documentation / publish (push) Successful in 24s

This commit is contained in:
User
2026-09-14 18:13:53 +02:00
parent b1723c34c4
commit c3caba94dd
35 changed files with 425 additions and 142 deletions
+16
View File
@@ -344,6 +344,22 @@ default, hover, focus, active, disabled, loading, and error behavior where those
In embedded mode the portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
actual application container. Narrow session document panels may use the available width.
### Session review and confirmations
Session dialogs use the visible application area, including the portal's header
and side rail. Artifact and schema-column review can grow to 80rem wide and the
available height; short confirmations use up to 40rem and at least 18rem when
space permits. Keep a 24px outer margin on desktop and 8px on small or short
screens. Long review content scrolls internally; on very short screens the
whole dialog can also scroll so every action remains reachable.
Session forms and review gates repeat their existing primary confirmation above
and below the content, sharing selection, validation, pending state and response
handlers. Alternate-response inputs follow the same rule. Reserved navigation
controls remain below the review. Stop/delete initially focus Cancel; rename
initially focuses the name field. Administration dialogs and forms retain their
existing layout and actions.
### Tabs
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
+15
View File
@@ -12,6 +12,21 @@ Authentik, internal catalog/embedding services, and the PSD workspace repository
`docs/operations/server-upgrade-gitea-workspace-v2.md`. Treat its operator gates and rollback
requirements as mandatory; do not replace the running server stack in place.
## Session review layout deployed — 2026-09-14
Session-only dialogs now use the visible app bounds: artifact/column review grows
up to 80rem wide and the available height; short confirmations grow to 40rem.
Existing primary actions appear above and below session forms and review content,
with shared handlers, validation and pending state. Stop/delete focus Cancel;
rename focuses the name field. Administration surfaces are unchanged. The activity
shortcut is relabelled To Administration / Vai all’Amministrazione, retaining its
workspace-management destination. 778 frontend tests, five responsive browser
scenarios, typecheck, translations and the production build passed. The owner
authorized deployment and the server frontend was recreated at 18:09 CEST with tag `b1723c34-session-dialogs-20260914`. Frontend is healthy,
Omics serves the new assets, and doctor passes 13/13 checks. Core and Omics web
were not restarted. See DESIGN.md for the layout contract and
`docs/reports/2026-09-14-session-dialogs-release.md` for provenance and rollback.
## Session composer and empty Memory fix deployed — 2026-09-14
The embedded shell now caps its height at the portal mount height, keeping steering
@@ -0,0 +1,73 @@
# Dialog di sessione: rilascio server del 14 settembre 2026
Aggiornamento autorizzato dall'utente ed eseguito alle 18:09 CEST. Il solo frontend
è stato ricreato; core e Omics web mantengono ID e timestamp di avvio precedenti.
La modifica comprende dialog di sessione più ampi e contenuti nella vista,
conferme sopra e sotto le form, e l'etichetta To Administration / Vai
all’Amministrazione. Le superfici amministrative e il workflow restano invariati.
## Provenienza e configurazione
- Base sorgente: `b1723c34`, più le modifiche frontend della working tree verificate.
- Checkout di build: `/home/chirone/Thoth`; i 32 file frontend interessati sono
stati allineati anche in `/srv/thothii-v2/source/ThothII`, conservando i precedenti.
- Immagine: `thothii-v2-frontend:b1723c34-session-dialogs-20260914`.
- Image ID: `sha256:d2ed3dec42f8a56536ebbc8d74f13892d4c42c9a7f2fb67c476ff39f43fe7af9`.
- Container: `becadeb12b5d9bd380700813e1f489e7f5674575b98ea0d00c6b7d751c890278`.
- Avvio: `2026-09-14T16:09:12.835900768Z`.
- Il solo campo `services.frontend.image` dell'override operativo
`/srv/thothii-v2/operator/compose.portal-upstream.yaml` è fissato al nuovo tag.
Il tag condiviso in `operator.env` non è cambiato: core e job mantengono
`49333a2d-session-memory-fix`. I prossimi aggiornamenti frontend devono aggiornare
esplicitamente questo campo, oppure ripristinarne l'interpolazione condivisa.
- Nessuna migrazione, modifica di dati, credenziali, modello o auth.
## Verifiche
Prima della distribuzione: 778 test frontend, cinque scenari browser responsive
(320/390/844/1280/1440 px, full e embedded), typecheck, traduzioni, build Vite e
Docker. Smoke della configurazione embedded montata nella nuova immagine superato.
Dopo la distribuzione:
- frontend healthy, core e Omics web invariati e healthy;
- `nginx -t` e reload Omics riusciti;
- config embedded e asset nuovi HTTP 200 tramite nginx Omics locale con Host reale;
- JavaScript `index-inK74FWS.js` contiene entrambe le nuove etichette;
- CSS `index-4WPDUJu7.css` contiene le regole `.thot-session-dialog`;
- manifest nuovo raggiungibile dal container Omics; trascorso il TTL di 30 secondi;
- API `/me` senza login ancora HTTP 403;
- `tht doctor --json`: `ok: true`, 13/13 controlli superati.
I test browser usano dati simulati; l'accettazione della resa nella sessione
Omics autenticata dell'utente richiede di ricaricare la pagina. Non sono state
create sessioni reali né invocati modelli per il collaudo.
## Backup e rollback
Directory protetta: `/srv/thothii-v2/backups/20260914-session-dialogs` (0700).
Contiene override precedente, archivi sorgente prima/dopo, patch, inventario dei
container e checksum verificati. L'immagine precedente è conservata anche come
`thothii-v2-frontend:before-session-dialogs-20260914`.
Il launcher `compose.sh` nel backup conserva esattamente progetto, directory,
env file e ordine dei cinque file Compose della distribuzione. Rilascio eseguito:
```bash
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
```
Rollback applicativo, dopo aver verificato che non ci siano release successive:
```bash
sudo cp -p /srv/thothii-v2/backups/20260914-session-dialogs/compose.portal-upstream.yaml \
/srv/thothii-v2/operator/compose.portal-upstream.yaml
sudo /srv/thothii-v2/backups/20260914-session-dialogs/compose.sh \
up -d --no-deps --no-build --wait --wait-timeout 90 frontend
sudo docker exec omics_portal-nginx-1 nginx -t
sudo docker exec omics_portal-nginx-1 nginx -s reload
```
Il ripristino dei sorgenti è separato: gli archivi conservano esattamente i file
interessati. Un rollback grafico non richiede ripristino di database o indici.
+71
View File
@@ -619,3 +619,74 @@ for (const width of [390, 1280]) {
await expect(page.getByRole("dialog", { name: "Stop the session?" })).toBeVisible();
});
}
for (const [width, height, mode] of [
[390, 844, "embedded"], [1280, 900, "embedded"], [1440, 1000, "full"],
[844, 390, "embedded"], [320, 640, "embedded"],
] as const) {
test(`session confirmation layout stays within ${mode} view at ${width}x${height}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height });
await fixtures(page, mode);
await page.route("**/api/sessions", route => route.request().method() === "POST"
? route.fulfill({ json: { id: "layout-session" } }) : route.fallback());
await page.route("**/api/sessions/layout-session", route => route.fulfill({ json: { id: "layout-session", active: true, status: "open", workspace } }));
await page.goto("/");
if (mode === "embedded") await page.addStyleTag({ content: `
body { margin: 0; padding-top: 60px; }
#root { margin-left: ${width > 900 ? 160 : 0}px; height: calc(100dvh - 80px); overflow: hidden; }
` });
await expect(page.getByRole("textbox", { name: "New question", exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "New question", exact: true }).fill("Review the cohort");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Steering", exact: true })).toBeVisible();
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().applyEvent({ type: "ui_request", ui_request: {
id: "layout-gate", widget: "artifact-gate", title: "Verifica della domanda e dei criteri di inclusione",
artifact: { kind: "markdown", content: "## Criteri di inclusione\n\n" + "Verificare i pazienti inclusi nella coorte e il periodo di riferimento.\n\n".repeat(100) },
options: [{ id: "approve", label: "Conferma e procedi alla fase successiva" }], reserved: ["back", "other"],
} });
});
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeVisible();
const bounds = await dialog.boundingBox();
const area = await page.getByTestId("app-shell").boundingBox();
expect(area).not.toBeNull();
expect(bounds!.x).toBeGreaterThanOrEqual(area!.x);
expect(bounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(area!.x + area!.width);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
expect(bounds!.width).toBeGreaterThan(Math.min(1000, area!.width - 60));
expect(bounds!.height).toBeGreaterThan(area!.height * 0.8);
const confirmations = dialog.getByRole("button", { name: "Conferma e procedi alla fase successiva" });
await expect(confirmations).toHaveCount(2);
for (const button of await confirmations.all()) {
await button.scrollIntoViewIfNeeded();
await expect(button).toBeInViewport();
}
expect(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
const body = dialog.locator(".thot-session-dialog__body");
expect(await body.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true);
await body.evaluate(el => { el.scrollTop = el.scrollHeight; });
await page.screenshot({ path: testInfo.outputPath("session-review.png") });
await page.evaluate(async () => {
const path = "/src/store/sessionStore.ts";
const { useSessionStore } = await import(/* @vite-ignore */ path);
useSessionStore.getState().clearPending();
});
await page.getByRole("button", { name: "Stop and save session", exact: true }).click();
const stop = page.getByRole("dialog", { name: "Stop the session?" });
await expect(stop.getByRole("button", { name: "Stop & save", exact: true })).toHaveCount(2);
await expect(stop.getByRole("button", { name: "Cancel", exact: true })).toBeFocused();
const stopBounds = await stop.boundingBox();
expect(stopBounds!.width).toBeGreaterThan(Math.min(600, area!.width - 60));
expect(stopBounds!.y).toBeGreaterThanOrEqual(area!.y);
expect(stopBounds!.y + stopBounds!.height).toBeLessThanOrEqual(area!.y + area!.height);
await page.screenshot({ path: testInfo.outputPath("session-stop.png") });
await stop.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(stop).toHaveCount(0);
});
}
@@ -0,0 +1,31 @@
.thot-session-dialog[data-slot="dialog-content"] {
--session-dialog-space: 1rem;
--session-dialog-height: min(100dvh, var(--app-area-height, 100dvh));
top: calc(var(--app-area-top, 0px) + var(--session-dialog-height) / 2);
width: min(40rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
max-width: calc(100vw - var(--session-dialog-space));
min-height: min(18rem, calc(var(--session-dialog-height) - var(--session-dialog-space)));
max-height: calc(var(--session-dialog-height) - var(--session-dialog-space));
overflow: auto;
overscroll-behavior: contain;
overflow-wrap: anywhere;
padding: 1.5rem;
gap: 1.25rem;
}
.thot-session-dialog[data-review] {
width: min(80rem, calc(var(--app-area-width, 100vw) - var(--session-dialog-space)));
height: calc(var(--session-dialog-height) - var(--session-dialog-space));
}
.thot-session-dialog [data-slot="dialog-title"] { padding-right: 1.5rem; line-height: 1.4; }
.thot-session-dialog [data-slot="dialog-footer"] { margin: 0; border-radius: 0; }
.thot-session-dialog button { max-width: 100%; white-space: normal; height: auto; min-height: 2rem; }
.thot-session-dialog__body { min-height: 4rem; overflow: auto; overscroll-behavior: contain; }
@media (min-width: 640px) and (min-height: 600px) {
.thot-session-dialog[data-slot="dialog-content"] { --session-dialog-space: 3rem; }
}
@media (max-width: 639px), (max-height: 599px) {
.thot-session-dialog[data-slot="dialog-content"] { padding: 1rem; gap: 0.75rem; }
}
@@ -0,0 +1,9 @@
import type { ComponentProps } from "react";
import { DialogContent } from "./ui/dialog";
import "./SessionDialogContent.css";
/** Session-only sizing; administrative dialogs retain their own layout. */
export function SessionDialogContent({ className = "", review = false, ...props }:
ComponentProps<typeof DialogContent> & { review?: boolean }) {
return <DialogContent {...props} className={`thot-session-dialog ${className}`} data-review={review || undefined} />;
}
+1
View File
@@ -47,6 +47,7 @@ export const itWorkflow: Record<string, string> = {
"Memory for future questions": "Memory per le domande future",
"Memory summary": "Riepilogo delle memorie",
"Model activity": "Attività del modello",
"To Administration": "Vai all’Amministrazione",
"Model activity timeline": "Cronologia dell'attività del modello",
"Move to group ›": "Sposta nel gruppo ›",
"Navigation": "Navigazione",
@@ -273,7 +273,7 @@ test("administrator confirms before deleting a same-named user's session", async
await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" }));
expect(deletes).toBe(0);
expect(await screen.findByRole("heading", { name: "Delete permanently" })).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
await userEvent.click(screen.getAllByRole("button", { name: "Delete" })[0]);
await waitFor(() => expect(deletes).toBe(1));
});
@@ -1253,7 +1253,7 @@ test("renaming a group reassigns its members via setSessionGroup", async () => {
const input = await screen.findByLabelText(/name/i, { selector: "input" });
await userEvent.clear(input);
await userEvent.type(input, "Cardiologia");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[0]);
await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }]));
});
@@ -61,7 +61,7 @@ test("a held stop for s1 cannot reset the newer active s2 session", async () =>
await userEvent.click(await screen.findByTestId("session-item-s1"));
await waitFor(() => expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events"));
await userEvent.click(screen.getByRole("button", { name: /stop and save session/i }));
await userEvent.click(await screen.findByRole("button", { name: "Stop & save" }));
await userEvent.click((await screen.findAllByRole("button", { name: "Stop & save" }))[0]);
await closeStarted.promise;
await userEvent.click(screen.getByTestId("session-item-s2"));
+8 -2
View File
@@ -117,8 +117,7 @@ export function AppShell({ canLogout }: AppShellProps) {
resizing: sessionResizing,
separatorProps: sessionSeparatorProps,
} = useSessionPanelResize(containerRef, panelSession !== null);
// Publish the app area's horizontal geometry so viewport-fixed dialogs center on
// the application area rather than the whole browser window.
// Publish the visible app bounds for dialogs, including embedded portal layouts.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
@@ -127,16 +126,23 @@ export function AppShell({ canLogout }: AppShellProps) {
const r = el.getBoundingClientRect();
doc.style.setProperty("--app-area-left", `${r.left}px`);
doc.style.setProperty("--app-area-width", `${r.width}px`);
const top = Math.max(0, r.top);
doc.style.setProperty("--app-area-top", `${top}px`);
doc.style.setProperty("--app-area-height", `${Math.max(0, Math.min(window.innerHeight, r.bottom) - top)}px`);
};
publish();
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(publish);
observer?.observe(el);
window.addEventListener("resize", publish);
window.addEventListener("scroll", publish, true);
return () => {
observer?.disconnect();
window.removeEventListener("resize", publish);
window.removeEventListener("scroll", publish, true);
doc.style.removeProperty("--app-area-left");
doc.style.removeProperty("--app-area-width");
doc.style.removeProperty("--app-area-top");
doc.style.removeProperty("--app-area-height");
};
}, [containerRef]);
const panelWidthsStyle = {
+11 -5
View File
@@ -1,5 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useId } from "react";
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -13,22 +15,26 @@ interface Props {
export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const cancelId = useId();
const targets = labels ?? (label ? [label] : []);
const description = targets.length > 1
? translate("{count} sessions will be permanently deleted, including all of their documents. This action cannot be undone.", { count: targets.length })
: translate('"{name}" will be permanently deleted, including all of its documents. This action cannot be undone.', { name: targets[0] ?? "" });
const confirmAction = (<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={() => document.getElementById(cancelId)}>
<DialogHeader><DialogTitle>{translate("Delete permanently")}</DialogTitle></DialogHeader>
<div>{confirmAction}</div>
<DialogDescription>
{description}
</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Delete")}</Button>
<Button id={cancelId} type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
{confirmAction}
</DialogFooter>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -92,8 +92,8 @@ export function ModelActivityPanel({
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<h2 className="font-heading text-sm font-semibold text-foreground">{translate("Model activity")}</h2>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" aria-label={translate("Workspace management")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("Workspace management")}</Button>
<Button size="sm" variant="ghost" aria-label={translate("To Administration")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("To Administration")}</Button>
<Button size="icon-sm" variant="ghost" aria-label={translate("Close model activity")} onClick={onClose}>
<X className="size-4" />
</Button>
+9 -5
View File
@@ -1,6 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useI18n } from "../i18n";
import { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
import { useEffect, useRef, useState } from "react";
import { Dialog, DialogHeader, DialogTitle, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -13,6 +14,7 @@ interface Props {
export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: Props) {
const { t: translate } = useI18n();
const nameRef = useRef<HTMLInputElement>(null);
const [name, setName] = useState(initial);
useEffect(() => { if (open) setName(initial); }, [open, initial]);
@@ -25,12 +27,14 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: P
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={nameRef}>
<DialogHeader><DialogTitle>{title ?? translate("Rename session")}</DialogTitle></DialogHeader>
<form onSubmit={submit} className="flex flex-col gap-3">
<form onSubmit={submit} className="flex flex-col gap-4">
<div><Button type="submit" size="sm">{translate("Save")}</Button></div>
<div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground" htmlFor="rename-name">{translate("Name")}</label>
<input
ref={nameRef}
id="rename-name"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -43,7 +47,7 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: P
<Button type="submit" size="sm">{translate("Save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+6 -6
View File
@@ -4,30 +4,30 @@ import { vi } from "vitest";
import { RenameDialog } from "./RenameDialog";
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
test("RenameDialog submits the edited name", async () => {
test.each([0, 1])("RenameDialog submits the edited name (position %s)", async (position) => {
const onSubmit = vi.fn();
render(<RenameDialog open initial="Vecchio" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
const input = screen.getByDisplayValue("Vecchio");
await userEvent.clear(input);
await userEvent.type(input, "Nuovo");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[position]);
expect(onSubmit).toHaveBeenCalledWith("Nuovo");
});
test("DeleteConfirmDialog confirms only on the destructive button", async () => {
test.each([0, 1])("DeleteConfirmDialog confirms only on the destructive button (position %s)", async (position) => {
const onConfirm = vi.fn();
render(<DeleteConfirmDialog open label="Sessione X" onOpenChange={vi.fn()} onConfirm={onConfirm} />);
expect(screen.getByText(/Sessione X/)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /delete/i }));
await userEvent.click(screen.getAllByRole("button", { name: /delete/i })[position]);
expect(onConfirm).toHaveBeenCalled();
});
test("RenameDialog does not submit an empty name", async () => {
test.each([0, 1])("RenameDialog does not submit an empty name (position %s)", async (position) => {
const onSubmit = vi.fn();
render(<RenameDialog open initial="Old" onOpenChange={vi.fn()} onSubmit={onSubmit} />);
const input = screen.getByDisplayValue("Old");
await userEvent.clear(input);
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await userEvent.click(screen.getAllByRole("button", { name: /save/i })[position]);
expect(onSubmit).not.toHaveBeenCalled();
});
+11 -5
View File
@@ -1,5 +1,7 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useId } from "react";
import { useI18n } from "../i18n";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Dialog, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
import { Button } from "../components/ui/button";
interface Props {
@@ -10,17 +12,21 @@ interface Props {
export function StopConfirmDialog({ open, onOpenChange, onConfirm }: Props) {
const { t: translate } = useI18n();
const cancelId = useId();
const confirmAction = (<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Stop & save")}</Button>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<SessionDialogContent initialFocus={() => document.getElementById(cancelId)}>
<DialogHeader><DialogTitle>{translate("Stop the session?")}</DialogTitle></DialogHeader>
<div>{confirmAction}</div>
<DialogDescription>
{translate("The running process will be interrupted. Progress so far is saved, and you can resume the session later.")}</DialogDescription>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>{translate("Stop & save")}</Button>
<Button id={cancelId} type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>{translate("Cancel")}</Button>
{confirmAction}
</DialogFooter>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+3 -3
View File
@@ -66,17 +66,17 @@ test("keeps answer drafts protected during a failed POST and retry, then clears
expect(reloadIsBlocked()).toBe(false);
await userEvent.type(screen.getByRole("textbox"), "Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(screen.getByRole("status")).toHaveTextContent("Sending response");
try {
expect(reloadIsBlocked()).toBe(true);
} finally {
release();
}
await waitFor(() => expect(screen.getByRole("button", { name: "Send" })).toBeEnabled());
await waitFor(() => expect(screen.getAllByRole("button", { name: "Send" })[0]).toBeEnabled());
expect(screen.getByRole("textbox")).toHaveValue("Unsaved answer");
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
await waitFor(() => expect(screen.queryByRole("textbox")).not.toBeInTheDocument());
expect(requests).toBe(2);
expect(reloadIsBlocked()).toBe(false);
@@ -14,7 +14,7 @@ test("localizes repair recovery without changing the recorded choice or archive
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Riprova la correzione selezionata" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "retry-it", choices: ["e"] });
});
@@ -32,7 +32,7 @@ test.each(["Memory", "Evidence"])("shows both contents and sends only the select
expect(screen.getByText("Before memory")).toBeVisible();
expect(screen.getByText("After memory")).toBeVisible();
expect(screen.getByText(/After evidence/)).toBeVisible();
const button = screen.getByRole("button", { name: `Apply this correction to ${archive}` });
const button = screen.getAllByRole("button", { name: `Apply this correction to ${archive}` })[0];
await userEvent.click(button);
expect(respond).toHaveBeenCalledWith({ id: "gate", choices: [archive === "Memory" ? "m" : "e"] });
expect(button).toBeDisabled();
@@ -53,7 +53,7 @@ test("saved but inactive corrections keep the choice fixed and offer retry", asy
repair: { ...repair, choice: "e", saved: true, status: "pending_activation" } }} onRespond={respond} />);
expect(screen.getByRole("status")).toHaveTextContent("Index activation is incomplete");
expect(screen.queryByRole("button", { name: /None are adequate/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Continue with repair pending" })).toBeVisible();
await userEvent.click(screen.getByRole("button", { name: "Retry selected correction" }));
expect(screen.getAllByRole("button", { name: "Continue with repair pending" })[0]).toBeVisible();
await userEvent.click(screen.getAllByRole("button", { name: "Retry selected correction" })[0]);
expect(respond).toHaveBeenCalledWith({ id: "retry", choices: ["e"] });
});
+16 -6
View File
@@ -62,7 +62,19 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
setSubmitted(true);
onRespond({ id: descriptor.id, choices: [choice] });
};
const correctionAction = (option: RepairOption) => (
<button type="button" onClick={() => send(option.id)}
className="rounded-md bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground">
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
</button>
);
const pending = repair.status === "applying" || repair.status === "pending_activation";
const retryAction = 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">
{translate("Retry selected correction")}
</button>
) : null;
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>
@@ -70,26 +82,24 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) {
{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">{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">
{retryAction && <div>{retryAction}</div>}
{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" ? translate("Memory") : translate("Evidence")} · {option.target_id}
{repair.choice === option.id && <> · {translate("Selected")}</>}</p></div>
{repair.can_apply && !repair.choice && correctionAction(option)}
<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">{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">{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">
{translate("Apply this correction to {archive}", { archive: translate(option.archive === "memory" ? "Memory" : "Evidence") })}
</button>}
{repair.can_apply && !repair.choice && correctionAction(option)}
</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">
{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">{translate("Retry selected correction")}</button>}
{retryAction}
{(repair.choice || !repair.can_apply) && <button type="button" onClick={() => send("continue")}
className="rounded-md border border-border px-3 py-2 text-sm">{pending ? translate("Continue with repair pending") : translate("Continue question review")}</button>}
</div>
@@ -66,7 +66,7 @@ test("a file-reference artifact resolves to the session document, rendered with
expect(screen.queryByText("question.md", { exact: true })).toBeNull();
});
test("clicking an option without opens responds immediately", async () => {
test.each([0, 1])("clicking an option without opens responds immediately (position %s)", async (position) => {
const onRespond = vi.fn();
render(
<ArtifactGateWidget
@@ -79,7 +79,7 @@ test("clicking an option without opens responds immediately", async () => {
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Approva/ })[position]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
});
+28 -21
View File
@@ -1,16 +1,17 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
import { useI18n } from "../i18n";
import { useState } from "react";
import type { WidgetProps } from "./types";
import type { UiResponse, WidgetDescriptor } from "../api/types";
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
import { Dialog, DialogTitle } from "../components/ui/dialog";
import { GateArtifactBody } from "./GateArtifactBody";
import { ReservedControls } from "./ReservedControls";
import { LinkageHost } from "./LinkageHost";
/**
* Artifact review gate rendered as a centered (70%) modal: the artifact fills
* the top (scrollable) area via ArtifactView; the action bar (options + reserved
* controls) sits at the bottom. The gate contract forbids silent dismissal, so the
* Artifact review fills the visible session area with scrollable content.
* The same options appear above and below it; reserved controls remain below.
* The gate contract forbids silent dismissal, so the
* dialog has no close button and is not closeable by Esc/backdrop — the only way
* out is an action or a reserved control, both of which call onRespond.
*/
@@ -28,17 +29,35 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
else onRespond(parentResponse);
}
const options = (<>
{descriptor.options?.map((o, i) => (
<button
key={o.id}
className={
i === 0
? "rounded-lg bg-primary px-3.5 py-2 text-left font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
: "rounded-lg border border-border/70 px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent"
}
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
</>);
return (
<Dialog open>
<DialogContent
<SessionDialogContent
showCloseButton={false}
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"
review
className="grid-rows-[auto_auto_minmax(4rem,1fr)_auto]"
>
<DialogTitle className="text-lg leading-snug">
{descriptor.title ?? translate("Artifact review")}
</DialogTitle>
<div className="flex flex-wrap gap-2">{!pendingLinkage && options}</div>
<div className="min-h-0 overflow-auto rounded-xl border border-border/70 bg-background p-5">
<div className="thot-session-dialog__body rounded-xl border border-border/70 bg-background p-3 sm:p-5">
{descriptor.artifact ? (
<GateArtifactBody artifact={descriptor.artifact} sessionId={sessionId} />
) : (
@@ -56,19 +75,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
) : (
<>
<div className="flex flex-wrap gap-2">
{descriptor.options?.map((o, i) => (
<button
key={o.id}
className={
i === 0
? "rounded-lg bg-primary px-3.5 py-2 text-left font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
: "rounded-lg border border-border/70 px-3 py-2 text-left shadow-xs transition-colors hover:bg-accent"
}
onClick={() => handleOption(o.id)}
>
{o.label}
</button>
))}
{options}
</div>
<ReservedControls
reserved={descriptor.reserved}
@@ -79,7 +86,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP
</>
)}
</div>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
+2 -2
View File
@@ -24,10 +24,10 @@ test("protects an answer draft until cleared or unmounted, including after sendi
unmount();
expect(reloadIsBlocked()).toBe(false);
});
test("submits typed text", async () => {
test.each([0, 1])("submits typed text (position %s)", async (position) => {
const onRespond = vi.fn();
render(<FreetextWidget descriptor={{ id: "u1", widget: "freetext" }} onRespond={onRespond} />);
await userEvent.type(screen.getByRole("textbox"), "ciao");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[position]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "freetext", text: "ciao" });
});
+10 -5
View File
@@ -12,6 +12,14 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
onRespond({ id: descriptor.id, kind: "freetext", text });
}
}
const confirmAction = (
<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}
>
{translate("Send")}</button>
);
return (
<div className="space-y-3 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
@@ -19,6 +27,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.title}
</p>
)}
<div>{confirmAction}</div>
<textarea
data-awaiting-input="true"
className="thot-awaiting-input min-h-24 w-full resize-y rounded-lg border p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow]"
@@ -28,11 +37,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) {
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}
>
{translate("Send")}</button>
{confirmAction}
</div>
);
}
@@ -25,7 +25,7 @@ const descriptor = {
],
} satisfies WidgetDescriptor;
test("renders joins as read-only information and Continue accepts the complete set", async () => {
test.each([0, 1])("renders joins as read-only information and Continue accepts the complete set (position %s)", async (position) => {
const onRespond = vi.fn();
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
@@ -34,7 +34,7 @@ test("renders joins as read-only information and Continue accepts the complete s
expect(screen.getByText(descriptor.options[0].detail)).toBeInTheDocument();
expect(screen.getByText("Patient grain")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
await userEvent.click(screen.getAllByRole("button", { name: "Continue" })[position]);
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
@@ -49,7 +49,7 @@ test("Other specify sends textual feedback instead of a join selection", async (
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
await userEvent.type(screen.getByPlaceholderText("Describe your alternative…"), "Use the episode key");
await userEvent.click(screen.getByRole("button", { name: "Send" }));
await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u-joins",
+15 -12
View File
@@ -7,6 +7,19 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
const { t: translate } = useI18n();
const options = descriptor.options ?? [];
const confirmAction = (
<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={() => onRespond({
id: descriptor.id,
kind: "join-review",
choices: options.map((option) => option.id),
})}
>
{descriptor.confirm_label ?? translate("Continue")}
</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
<div className="space-y-1.5">
@@ -19,6 +32,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
{translate("These joins are required by the selected tables and are shown for review. To request a correction, use Other — specify.")}</p>
</div>
<div>{confirmAction}</div>
<div className="space-y-2.5">
{options.map((option, index) => (
<article
@@ -59,18 +73,7 @@ export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
))}
</div>
<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={() =>
onRespond({
id: descriptor.id,
kind: "join-review",
choices: options.map((option) => option.id),
})
}
>
{descriptor.confirm_label ?? translate("Continue")}
</button>
{confirmAction}
<ReservedControls
reserved={descriptor.reserved}
@@ -30,7 +30,7 @@ test.each(["subject", "scope", "detail", "rationale", "question", "concepts"])(
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
@@ -50,7 +50,7 @@ test("protects Memory selection, dependency and link drafts, including invalid e
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(reloadIsBlocked()).toBe(true); // Required database is still empty.
await userEvent.click(screen.getByRole("button", { name: "Remove dependency" }));
expect(reloadIsBlocked()).toBe(false);
@@ -63,7 +63,7 @@ test("protects Memory selection, dependency and link drafts, including invalid e
expect(reloadIsBlocked()).toBe(false);
});
test("sends only selected cards with reviewer edits and physical dependencies", async () => {
test.each([0, 1])("sends only selected cards with reviewer edits and physical dependencies (position %s)", async (position) => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.clear(screen.getByLabelText("domain detail"));
@@ -73,20 +73,20 @@ test("sends only selected cards with reviewer edits and physical dependencies",
await userEvent.type(screen.getByLabelText("domain dependency 1 database"), "warehouse");
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[position]);
expect(onRespond).toHaveBeenCalledTimes(1);
const response = JSON.parse(onRespond.mock.calls[0]![0].text);
expect(response).toEqual({ summary_id: "hash", items: [{ id: "domain", card: { ...card,
detail: "Reviewer correction", dependencies: [{ database: "warehouse", schema_name: "clinical",
table: "patients", column: "" }] } }] });
expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled();
expect(screen.getAllByRole("button", { name: "Saving…" })[position]).toBeDisabled();
});
test("declining every proposal submits an explicit empty review", async () => {
const onRespond = vi.fn();
render(<MemoryReviewWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: "Deselect all" }));
await userEvent.click(screen.getByRole("button", { name: "Finish without saving Memory" }));
await userEvent.click(screen.getAllByRole("button", { name: "Finish without saving Memory" })[0]);
expect(JSON.parse(onRespond.mock.calls[0]![0].text)).toEqual({ summary_id: "hash", items: [] });
});
@@ -97,7 +97,7 @@ test("links selected proposals and keeps approved SQL read only", async () => {
expect(screen.queryByRole("textbox", { name: /sql/i })).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText("domain add link"), "proposal:solved");
await userEvent.type(screen.getByLabelText("domain link 1 meaning"), "Example using this definition");
await userEvent.click(screen.getByRole("button", { name: "Save selected and finish" }));
await userEvent.click(screen.getAllByRole("button", { name: "Save selected and finish" })[0]);
expect(JSON.parse(onRespond.mock.calls[0]![0].text).items[0].card.links)
.toEqual([{ target_id: "proposal:solved", meaning: "Example using this definition" }]);
});
@@ -117,7 +117,7 @@ test("switches review chrome to Italian without changing edited cards, SQL or re
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Salva le selezionate e termina" })[0]);
const response = onRespond.mock.calls[0][0];
expect(response).toMatchObject({ id: "review", kind: "memory-review" });
+8 -3
View File
@@ -29,6 +29,12 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const confirmAction = (
<button type="submit" className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground">
{submitted ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
</button>
);
return <form aria-label={translate("Memory summary")} className="space-y-5 rounded-xl border border-border bg-card p-5"
onSubmit={event => {
event.preventDefault();
@@ -45,6 +51,7 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
<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">
<div>{confirmAction}</div>
<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 ? translate("Deselect all") : translate("Select all")}
@@ -121,9 +128,7 @@ export function MemoryReviewWidget({ descriptor, onRespond }: WidgetProps) {
</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">{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 ? translate("Saving…") : selected.size ? translate("Save selected and finish") : translate("Finish without saving Memory")}
</button>
{confirmAction}
</div>
<ReservedControls reserved={descriptor.reserved} onControl={(control, text) => onRespond({ id: descriptor.id, control, text })} />
</fieldset>
+10 -10
View File
@@ -23,7 +23,7 @@ test("protects changed choices until reverted or unmounted, including after conf
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Confirm" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: [] });
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "Beta" }));
@@ -43,7 +43,7 @@ test("keeps active choices and model labels unchanged across a locale switch", a
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Conferma" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "review", kind: "multiselect", choices: ["a", "b"] });
});
@@ -63,7 +63,7 @@ test("confirms the checked ids including initially selected", async () => {
/>
);
await userEvent.click(screen.getByRole("checkbox", { name: /t2/ }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["t1", "t2"] });
});
@@ -87,7 +87,7 @@ test("uses memory-specific selection copy without changing selected ids", async
expect(screen.getByRole("checkbox", { name: /memory raccomandata/i })).toBeChecked();
expect(screen.getByText("1 / 2 memory da applicare")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Applica le memory selezionate" }));
await userEvent.click(screen.getAllByRole("button", { name: "Applica le memory selezionate" })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u-memory", kind: "multiselect", choices: ["recommended"] });
});
@@ -157,7 +157,7 @@ test("select-all checks all options", async () => {
/>
);
await userEvent.click(screen.getByRole("button", { name: /select all/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["a", "b"] });
});
@@ -177,11 +177,11 @@ test("deselect-all unchecks all options", async () => {
/>
);
await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: [] });
});
test("confirm is disabled when nothing checked and allow_empty is false", async () => {
test.each([0, 1])("confirm is disabled when nothing checked and allow_empty is false (position %s)", async (position) => {
const onRespond = vi.fn();
render(
<MultiselectWidget
@@ -194,7 +194,7 @@ test("confirm is disabled when nothing checked and allow_empty is false", async
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[position];
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onRespond).not.toHaveBeenCalled();
@@ -216,7 +216,7 @@ test("deselect-all with allow_empty false disables Conferma (empty confirm impos
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[0];
expect(btn).not.toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /deselect all/i }));
expect(btn).toBeDisabled();
@@ -237,7 +237,7 @@ test("confirm is enabled when allow_empty is false but something is checked", as
onRespond={onRespond}
/>
);
const btn = screen.getByRole("button", { name: /confirm/i });
const btn = screen.getAllByRole("button", { name: /confirm/i })[0];
expect(btn).not.toBeDisabled();
await userEvent.click(btn);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "multiselect", choices: ["x"] });
+12 -7
View File
@@ -26,6 +26,16 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
const isDisabled = descriptor.allow_empty === false && checked.size === 0;
const confirmAction = (
<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))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
{descriptor.confirm_label ?? translate("Confirm")}
</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
@@ -33,6 +43,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
{descriptor.title}
</p>
)}
<div>{confirmAction}</div>
<div className="flex flex-col">
{/* Prominent select-all control at the head of the list. */}
<div className="flex items-center justify-between border-b border-border/60 pb-2">
@@ -97,13 +108,7 @@ export function MultiselectWidget({ descriptor, onRespond }: WidgetProps) {
</div>
</div>
<div>
<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))] disabled:cursor-not-allowed disabled:opacity-50"
disabled={isDisabled}
onClick={() => onRespond({ id: descriptor.id, kind: "multiselect", choices: Array.from(checked) })}
>
{descriptor.confirm_label ?? translate("Confirm")}
</button>
{confirmAction}
</div>
<ReservedControls
reserved={descriptor.reserved}
@@ -18,15 +18,15 @@ test("other reveals a textarea and emits freetext with the typed text", async ()
// clicking Other does NOT emit a control yet — it reveals the input
expect(onControl).not.toHaveBeenCalled();
await userEvent.type(screen.getByRole("textbox"), "usa la tabella X");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[0]);
expect(onControl).toHaveBeenCalledWith("freetext", "usa la tabella X");
});
test("Send stays disabled until non-whitespace text is entered", async () => {
test.each([0, 1])("Send stays disabled until non-whitespace text is entered (position %s)", async (position) => {
const onControl = vi.fn();
render(<ReservedControls reserved={["other"]} onControl={onControl} />);
await userEvent.click(screen.getByRole("button", { name: /other — specify/i }));
const send = screen.getByRole("button", { name: /send/i });
const send = screen.getAllByRole("button", { name: /send/i })[position];
expect(send).toBeDisabled();
await userEvent.type(screen.getByRole("textbox"), " ");
expect(send).toBeDisabled();
@@ -55,7 +55,7 @@ test.each(["button", "Enter"])("protects Other drafts through %s submission unti
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" }));
else await userEvent.click(screen.getAllByRole("button", { name: "Send" })[0]);
expect(onControl).toHaveBeenCalledWith("freetext", "Correction");
expect(reloadIsBlocked()).toBe(true);
unmount();
+11 -7
View File
@@ -19,6 +19,15 @@ export function ReservedControls({
if (!text.trim()) return;
onControl("freetext", text);
}
const confirmAction = (
<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={submit}
>
{translate("Send")}</button>
);
if (!reserved?.length) return null;
return (
<div className="flex flex-col gap-2 border-t border-border/60 pt-3">
@@ -36,6 +45,7 @@ export function ReservedControls({
</div>
{otherOpen && (
<div className="flex flex-col gap-2">
<div>{confirmAction}</div>
<textarea
data-awaiting-input="true"
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]"
@@ -45,13 +55,7 @@ export function ReservedControls({
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={submit}
>
{translate("Send")}</button>
{confirmAction}
</div>
)}
</div>
+4 -3
View File
@@ -1,7 +1,8 @@
import { SessionDialogContent } from "../components/SessionDialogContent";
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";
import { Dialog, DialogTitle } from "../components/ui/dialog";
export function SchemaColumnsDialog({
table,
@@ -42,7 +43,7 @@ export function SchemaColumnsDialog({
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="grid max-h-[70vh] w-[70vw] max-w-[46rem] grid-rows-[auto_auto_1fr] gap-3 sm:max-w-[46rem]">
<SessionDialogContent review className="grid-rows-[auto_auto_minmax(4rem,1fr)]">
<DialogTitle className="font-mono text-sm">{table.name}</DialogTitle>
<div className="flex items-center gap-2">
<input
@@ -91,7 +92,7 @@ export function SchemaColumnsDialog({
)}
</ul>
</div>
</DialogContent>
</SessionDialogContent>
</Dialog>
);
}
@@ -56,7 +56,7 @@ test("protects changed tables and columns until reverted or unmounted, including
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" }));
await userEvent.click(screen.getAllByRole("button", { name: "Confirm" })[0]);
expect(onRespond).toHaveBeenCalledWith(expect.objectContaining({ kind: "schema-linking" }));
expect(reloadIsBlocked()).toBe(true);
await userEvent.click(screen.getByRole("checkbox", { name: "dim_patient" }));
@@ -72,10 +72,10 @@ test("starts include rows checked and exclude rows unchecked even for legacy pay
expect(screen.getByRole("checkbox", { name: "fact_sostituzione" })).not.toBeChecked();
});
test("Confirm emits enacted tables with suggested columns (catalog order)", async () => {
test.each([0, 1])("Confirm emits enacted tables with suggested columns (catalog order) (position %s)", async (position) => {
const onRespond = vi.fn();
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[position]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -93,7 +93,7 @@ test("selecting a column in the modal adds it to the response", async () => {
await userEvent.click(screen.getByRole("button", { name: /columns 1\/2/i }));
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
await userEvent.click(screen.getByRole("button", { name: /close/i })); // dialog X button
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -108,7 +108,7 @@ test("declining a table's enact checkbox drops its columns", async () => {
const onRespond = vi.fn();
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
await userEvent.click(screen.getByRole("checkbox", { name: /dim_patient/i }));
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
await userEvent.click(screen.getAllByRole("button", { name: /confirm/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u7",
kind: "schema-linking",
@@ -62,12 +62,21 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
const openTable = tables.find((t) => t.id === openId) ?? null;
const confirmAction = (
<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={confirm}
>
{translate("Confirm")}</button>
);
return (
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
{descriptor.title && (
<p className="text-base font-semibold leading-snug text-foreground">{descriptor.title}</p>
)}
<div>{confirmAction}</div>
<div className="flex flex-col gap-2">
{tables.map((t) => {
const on = enacted.has(t.id);
@@ -119,11 +128,7 @@ export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps)
)}
<div>
<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={confirm}
>
{translate("Confirm")}</button>
{confirmAction}
</div>
<ReservedControls
+1 -1
View File
@@ -38,7 +38,7 @@ test.each(cases)("%s protects Other drafts through submission until the host unm
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 }));
await userEvent.click(screen.getAllByRole("button", { name: action })[0]);
expect(onRespond).toHaveBeenCalledTimes(1);
expect(reloadIsBlocked()).toBe(true);
unmount();
+3 -3
View File
@@ -22,9 +22,9 @@ test("reject opens a freetext and combines the reason", async () => {
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Rifiuta/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Rifiuta/ })[0]);
await userEvent.type(screen.getByRole("textbox"), "join sbagliata");
await userEvent.click(screen.getByRole("button", { name: /send/i }));
await userEvent.click(screen.getAllByRole("button", { name: /send/i })[0]);
expect(onRespond).toHaveBeenCalledWith({
id: "u1",
kind: "artifact-gate",
@@ -53,7 +53,7 @@ test("approve (no opens) responds immediately without child widget", async () =>
onRespond={onRespond}
/>
);
await userEvent.click(screen.getByRole("button", { name: /Approva/ }));
await userEvent.click(screen.getAllByRole("button", { name: /Approva/ })[0]);
expect(onRespond).toHaveBeenCalledWith({ id: "u1", kind: "artifact-gate", choices: ["approve"] });
// No textbox should appear
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();