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
@@ -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);