From 9a9df008d66973d8be0d58ce6b174b67f91e6585 Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Mon, 10 Aug 2026 09:41:16 +0500 Subject: [PATCH] fix(desktop): keep error toasts on screen until dismissed Agent and team deletion errors used Sonner's four-second default and had no close control. Those messages run to several lines and are often worth copying into a report, so the timer regularly cleared them before they could be read. Error feedback now stays up until it is dismissed and carries Sonner's close control, whose default accessible name is "Close toast". Success notices are untouched and keep the short default lifetime. Closes #5362 Signed-off-by: Seydi Charyyev --- .../src/shared/hooks/useToastEffect.test.mjs | 19 +++++++++++++++++++ desktop/src/shared/hooks/useToastEffect.ts | 19 +++++++++++++++++-- desktop/tests/e2e/agents.spec.ts | 17 ++++++++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 desktop/src/shared/hooks/useToastEffect.test.mjs 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 }) => {