diff --git a/desktop/src/shared/hooks/useToastEffect.test.mjs b/desktop/src/shared/hooks/useToastEffect.test.mjs new file mode 100644 index 0000000000..24c38e8bbd --- /dev/null +++ b/desktop/src/shared/hooks/useToastEffect.test.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { feedbackToastOptions } from "./useToastEffect.ts"; + +test("an error toast is given an explicit close control", () => { + assert.equal(feedbackToastOptions("error").closeButton, true); +}); + +test("an error toast is not on a timer", () => { + const { duration } = feedbackToastOptions("error"); + // Sonner treats a non-finite duration as "never auto-close"; anything finite + // would put a multi-line error back into a race with the reader. + assert.equal(Number.isFinite(duration), false); +}); + +test("a success toast keeps Sonner's defaults", () => { + assert.equal(feedbackToastOptions("success"), undefined); +}); diff --git a/desktop/src/shared/hooks/useToastEffect.ts b/desktop/src/shared/hooks/useToastEffect.ts index 7aaa6d02ab..9227562109 100644 --- a/desktop/src/shared/hooks/useToastEffect.ts +++ b/desktop/src/shared/hooks/useToastEffect.ts @@ -1,6 +1,21 @@ import * as React from "react"; import { toast } from "sonner"; +type ToastVariant = "success" | "error"; + +/** + * Errors are the one class of feedback still needed after it is read: they run + * to several lines and are often worth copying into a bug report. Sonner's + * four-second default races that, so an error stays up until it is dismissed + * and carries an explicit close control. A success notice says nothing that + * outlives the glance, so it keeps the default treatment. + */ +export function feedbackToastOptions(variant: ToastVariant) { + return variant === "error" + ? { closeButton: true, duration: Number.POSITIVE_INFINITY } + : undefined; +} + /** * Show a toast when a message string becomes truthy. Uses a ref to avoid * double-firing in React StrictMode (where effects run twice with the same @@ -8,14 +23,14 @@ import { toast } from "sonner"; */ function useToastEffect( message: string | null | undefined, - variant: "success" | "error", + variant: ToastVariant, ) { const shownRef = React.useRef(null); React.useEffect(() => { if (message && message !== shownRef.current) { shownRef.current = message; - toast[variant](message); + toast[variant](message, feedbackToastOptions(variant)); } if (!message) { shownRef.current = null; diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 66f9e4e707..08e270acde 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -2415,11 +2415,18 @@ test("built-in removal failures show up from My Agents", async ({ page }) => { await page.getByLabel("Open actions for Honey").click(); await page.getByRole("menuitem", { name: "Delete" }).click(); - await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Honey is still referenced by a team." }), - ).toBeVisible(); + const failureToast = page + .locator("[data-sonner-toast]") + .filter({ hasText: "Honey is still referenced by a team." }); + await expect(failureToast).toBeVisible(); + + // The reported failure: Sonner's four-second default cleared this before a + // multi-line error could be read, let alone copied into a report. + await page.waitForTimeout(5_000); + await expect(failureToast).toBeVisible(); + + await failureToast.getByRole("button", { name: "Close toast" }).click(); + await expect(failureToast).toHaveCount(0); }); test("personas referenced by teams cannot be deleted", async ({ page }) => {