From c3caba94dddf2bae2f92ae4508f8a5ff18750577 Mon Sep 17 00:00:00 2001 From: User Date: Mon, 14 Sep 2026 18:13:53 +0200 Subject: [PATCH] fix(ui): expand session dialogs and repeat confirmation actions --- DESIGN.md | 16 ++++ PROJECT_STATE.md | 15 ++++ .../2026-09-14-session-dialogs-release.md | 73 +++++++++++++++++++ frontend/e2e/ui-visual-review.spec.ts | 71 ++++++++++++++++++ .../src/components/SessionDialogContent.css | 31 ++++++++ .../src/components/SessionDialogContent.tsx | 9 +++ frontend/src/i18n/locales/it-workflow.ts | 1 + .../src/shell/AppShell.session-mgmt.test.tsx | 4 +- .../shell/AppShell.session-target.test.tsx | 2 +- frontend/src/shell/AppShell.tsx | 10 ++- frontend/src/shell/DeleteConfirmDialog.tsx | 16 ++-- frontend/src/shell/ModelActivityPanel.tsx | 4 +- frontend/src/shell/RenameDialog.tsx | 14 ++-- frontend/src/shell/SessionActions.test.tsx | 12 +-- frontend/src/shell/StopConfirmDialog.tsx | 16 ++-- frontend/src/shell/WidgetHost.test.tsx | 6 +- .../src/widgets/ArchiveRepairWidget.test.tsx | 8 +- frontend/src/widgets/ArchiveRepairWidget.tsx | 22 ++++-- .../src/widgets/ArtifactGateWidget.test.tsx | 4 +- frontend/src/widgets/ArtifactGateWidget.tsx | 49 +++++++------ frontend/src/widgets/FreetextWidget.test.tsx | 4 +- frontend/src/widgets/FreetextWidget.tsx | 15 ++-- .../src/widgets/JoinReviewWidget.test.tsx | 6 +- frontend/src/widgets/JoinReviewWidget.tsx | 27 ++++--- .../src/widgets/MemoryReviewWidget.test.tsx | 16 ++-- frontend/src/widgets/MemoryReviewWidget.tsx | 11 ++- .../src/widgets/MultiselectWidget.test.tsx | 20 ++--- frontend/src/widgets/MultiselectWidget.tsx | 19 +++-- .../src/widgets/ReservedControls.test.tsx | 8 +- frontend/src/widgets/ReservedControls.tsx | 18 +++-- frontend/src/widgets/SchemaColumnsDialog.tsx | 7 +- .../widgets/SchemaLinkingGateWidget.test.tsx | 10 +-- .../src/widgets/SchemaLinkingGateWidget.tsx | 15 ++-- frontend/src/widgets/WidgetDrafts.test.tsx | 2 +- frontend/src/widgets/linkage.test.tsx | 6 +- 35 files changed, 425 insertions(+), 142 deletions(-) create mode 100644 docs/reports/2026-09-14-session-dialogs-release.md create mode 100644 frontend/src/components/SessionDialogContent.css create mode 100644 frontend/src/components/SessionDialogContent.tsx diff --git a/DESIGN.md b/DESIGN.md index bb07e807..a2057e0a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 6a42714a..061bc0af 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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 diff --git a/docs/reports/2026-09-14-session-dialogs-release.md b/docs/reports/2026-09-14-session-dialogs-release.md new file mode 100644 index 00000000..58c9818a --- /dev/null +++ b/docs/reports/2026-09-14-session-dialogs-release.md @@ -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. diff --git a/frontend/e2e/ui-visual-review.spec.ts b/frontend/e2e/ui-visual-review.spec.ts index 27e9be69..e0905adf 100644 --- a/frontend/e2e/ui-visual-review.spec.ts +++ b/frontend/e2e/ui-visual-review.spec.ts @@ -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); + }); +} diff --git a/frontend/src/components/SessionDialogContent.css b/frontend/src/components/SessionDialogContent.css new file mode 100644 index 00000000..495dcf7a --- /dev/null +++ b/frontend/src/components/SessionDialogContent.css @@ -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; } +} diff --git a/frontend/src/components/SessionDialogContent.tsx b/frontend/src/components/SessionDialogContent.tsx new file mode 100644 index 00000000..ea24f8c6 --- /dev/null +++ b/frontend/src/components/SessionDialogContent.tsx @@ -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 & { review?: boolean }) { + return ; +} diff --git a/frontend/src/i18n/locales/it-workflow.ts b/frontend/src/i18n/locales/it-workflow.ts index 4c9b9a4c..6e505df9 100644 --- a/frontend/src/i18n/locales/it-workflow.ts +++ b/frontend/src/i18n/locales/it-workflow.ts @@ -47,6 +47,7 @@ export const itWorkflow: Record = { "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", diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 3819dc9a..2b65cc6f 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -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" }])); }); diff --git a/frontend/src/shell/AppShell.session-target.test.tsx b/frontend/src/shell/AppShell.session-target.test.tsx index cad0629d..ea11e5d4 100644 --- a/frontend/src/shell/AppShell.session-target.test.tsx +++ b/frontend/src/shell/AppShell.session-target.test.tsx @@ -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")); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 442f9b81..2a7409a4 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -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 = { diff --git a/frontend/src/shell/DeleteConfirmDialog.tsx b/frontend/src/shell/DeleteConfirmDialog.tsx index 07eb7f8f..a0b77b72 100644 --- a/frontend/src/shell/DeleteConfirmDialog.tsx +++ b/frontend/src/shell/DeleteConfirmDialog.tsx @@ -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 = (); + return ( - + document.getElementById(cancelId)}> {translate("Delete permanently")} +
{confirmAction}
{description} - - + + {confirmAction} -
+
); } diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index 4d00bbb5..f3df75ea 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -92,8 +92,8 @@ export function ModelActivityPanel({

{translate("Model activity")}

- + diff --git a/frontend/src/shell/RenameDialog.tsx b/frontend/src/shell/RenameDialog.tsx index 1add1c13..cceb96a5 100644 --- a/frontend/src/shell/RenameDialog.tsx +++ b/frontend/src/shell/RenameDialog.tsx @@ -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(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 ( - + {title ?? translate("Rename session")} -
+ +
setName(e.target.value)} @@ -43,7 +47,7 @@ export function RenameDialog({ open, initial, onOpenChange, onSubmit, title }: P - +
); } diff --git a/frontend/src/shell/SessionActions.test.tsx b/frontend/src/shell/SessionActions.test.tsx index a0daa851..dca8b5da 100644 --- a/frontend/src/shell/SessionActions.test.tsx +++ b/frontend/src/shell/SessionActions.test.tsx @@ -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(); 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(); 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(); 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(); }); diff --git a/frontend/src/shell/StopConfirmDialog.tsx b/frontend/src/shell/StopConfirmDialog.tsx index 75ab1ee6..bd8def9f 100644 --- a/frontend/src/shell/StopConfirmDialog.tsx +++ b/frontend/src/shell/StopConfirmDialog.tsx @@ -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 = (); + return ( - + document.getElementById(cancelId)}> {translate("Stop the session?")} +
{confirmAction}
{translate("The running process will be interrupted. Progress so far is saved, and you can resume the session later.")} - - + + {confirmAction} -
+
); } diff --git a/frontend/src/shell/WidgetHost.test.tsx b/frontend/src/shell/WidgetHost.test.tsx index cf145a2c..03f4a50d 100644 --- a/frontend/src/shell/WidgetHost.test.tsx +++ b/frontend/src/shell/WidgetHost.test.tsx @@ -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); diff --git a/frontend/src/widgets/ArchiveRepairWidget.test.tsx b/frontend/src/widgets/ArchiveRepairWidget.test.tsx index efd0550d..cd98c006 100644 --- a/frontend/src/widgets/ArchiveRepairWidget.test.tsx +++ b/frontend/src/widgets/ArchiveRepairWidget.test.tsx @@ -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"] }); }); diff --git a/frontend/src/widgets/ArchiveRepairWidget.tsx b/frontend/src/widgets/ArchiveRepairWidget.tsx index 0c393da7..ade1bbb2 100644 --- a/frontend/src/widgets/ArchiveRepairWidget.tsx +++ b/frontend/src/widgets/ArchiveRepairWidget.tsx @@ -62,7 +62,19 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) { setSubmitted(true); onRespond({ id: descriptor.id, choices: [choice] }); }; + const correctionAction = (option: RepairOption) => ( + + ); const pending = repair.status === "applying" || repair.status === "pending_activation"; + const retryAction = pending && repair.can_apply && repair.choice ? ( + + ) : null; return

{translate("Resolve archive conflict")}

{repair.reason}

@@ -70,26 +82,24 @@ export function ArchiveRepairWidget({ descriptor, onRespond }: WidgetProps) { {Boolean(descriptor.error) &&

{String(descriptor.error)}

} {!repair.can_apply &&

{translate("An administrator must approve shared archive corrections. You can reject the proposals or continue reviewing this question.")}

}
+ {retryAction &&
{retryAction}
} {repair.options.map(option =>

{option.label}

{option.archive === "memory" ? translate("Memory") : translate("Evidence")} · {option.target_id} {repair.choice === option.id && <> · {translate("Selected")}}

+ {repair.can_apply && !repair.choice && correctionAction(option)}

{translate("Before this proposal")}

{translate("Proposed result")}

- {repair.can_apply && !repair.choice && } + {repair.can_apply && !repair.choice && correctionAction(option)}
)}
{!repair.choice && } - {pending && repair.can_apply && repair.choice && } + {retryAction} {(repair.choice || !repair.can_apply) && }
diff --git a/frontend/src/widgets/ArtifactGateWidget.test.tsx b/frontend/src/widgets/ArtifactGateWidget.test.tsx index 8008d59c..36840094 100644 --- a/frontend/src/widgets/ArtifactGateWidget.test.tsx +++ b/frontend/src/widgets/ArtifactGateWidget.test.tsx @@ -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( { 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"] }); }); diff --git a/frontend/src/widgets/ArtifactGateWidget.tsx b/frontend/src/widgets/ArtifactGateWidget.tsx index 0a1815d2..dfb15c11 100644 --- a/frontend/src/widgets/ArtifactGateWidget.tsx +++ b/frontend/src/widgets/ArtifactGateWidget.tsx @@ -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) => ( + + ))} + ); + return ( - {descriptor.title ?? translate("Artifact review")} +
{!pendingLinkage && options}
-
+
{descriptor.artifact ? ( ) : ( @@ -56,19 +75,7 @@ export function ArtifactGateWidget({ descriptor, onRespond, sessionId }: WidgetP ) : ( <>
- {descriptor.options?.map((o, i) => ( - - ))} + {options}
)}
- +
); } diff --git a/frontend/src/widgets/FreetextWidget.test.tsx b/frontend/src/widgets/FreetextWidget.test.tsx index 0c8b8280..9c023a1a 100644 --- a/frontend/src/widgets/FreetextWidget.test.tsx +++ b/frontend/src/widgets/FreetextWidget.test.tsx @@ -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(); 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" }); }); diff --git a/frontend/src/widgets/FreetextWidget.tsx b/frontend/src/widgets/FreetextWidget.tsx index 923690d4..a4624d6c 100644 --- a/frontend/src/widgets/FreetextWidget.tsx +++ b/frontend/src/widgets/FreetextWidget.tsx @@ -12,6 +12,14 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) { onRespond({ id: descriptor.id, kind: "freetext", text }); } } + const confirmAction = ( + + ); + return (
{descriptor.title && ( @@ -19,6 +27,7 @@ export function FreetextWidget({ descriptor, onRespond }: WidgetProps) { {descriptor.title}

)} +
{confirmAction}