From 7cfecb6e52319f227ad4b7ac9feed61d1b8921ca Mon Sep 17 00:00:00 2001 From: Xingyao Wang Date: Mon, 7 Jul 2025 14:33:47 -0400 Subject: [PATCH] Increase success toast duration to 5 seconds with dynamic calculation (#9574) Co-authored-by: openhands --- .../__tests__/custom-toast-handlers.test.ts | 104 ++++++++++++++++++ .../utils/__tests__/toast-duration.test.ts | 53 +++++++++ frontend/src/utils/custom-toast-handlers.tsx | 7 +- frontend/src/utils/toast-duration.ts | 27 +++++ frontend/src/utils/toast.tsx | 3 +- 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/__tests__/custom-toast-handlers.test.ts create mode 100644 frontend/src/utils/__tests__/toast-duration.test.ts create mode 100644 frontend/src/utils/toast-duration.ts diff --git a/frontend/src/utils/__tests__/custom-toast-handlers.test.ts b/frontend/src/utils/__tests__/custom-toast-handlers.test.ts new file mode 100644 index 0000000000..09023b517a --- /dev/null +++ b/frontend/src/utils/__tests__/custom-toast-handlers.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import toast from "react-hot-toast"; +import { + displaySuccessToast, + displayErrorToast, +} from "../custom-toast-handlers"; + +// Mock react-hot-toast +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +describe("custom-toast-handlers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("displaySuccessToast", () => { + it("should call toast.success with calculated duration for short message", () => { + const shortMessage = "Settings saved"; + displaySuccessToast(shortMessage); + + expect(toast.success).toHaveBeenCalledWith( + shortMessage, + expect.objectContaining({ + duration: 5000, // Should use minimum duration of 5000ms + position: "top-right", + style: expect.any(Object), + }), + ); + }); + + it("should call toast.success with longer duration for long message", () => { + const longMessage = + "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes."; + displaySuccessToast(longMessage); + + expect(toast.success).toHaveBeenCalledWith( + longMessage, + expect.objectContaining({ + duration: expect.any(Number), + position: "top-right", + style: expect.any(Object), + }), + ); + + // Get the actual duration that was passed + const callArgs = ( + toast.success as unknown as { mock: { calls: unknown[][] } } + ).mock.calls[0][1] as { duration: number }; + const actualDuration = callArgs.duration; + + // For a long message, duration should be more than the minimum 5000ms + expect(actualDuration).toBeGreaterThan(5000); + // But should not exceed the maximum 10000ms + expect(actualDuration).toBeLessThanOrEqual(10000); + }); + }); + + describe("displayErrorToast", () => { + it("should call toast.error with calculated duration for short message", () => { + const shortMessage = "Error occurred"; + displayErrorToast(shortMessage); + + expect(toast.error).toHaveBeenCalledWith( + shortMessage, + expect.objectContaining({ + duration: 4000, // Should use minimum duration of 4000ms for errors + position: "top-right", + style: expect.any(Object), + }), + ); + }); + + it("should call toast.error with longer duration for long error message", () => { + const longMessage = + "A very long error message that should take more time to read and understand what went wrong with the operation."; + displayErrorToast(longMessage); + + expect(toast.error).toHaveBeenCalledWith( + longMessage, + expect.objectContaining({ + duration: expect.any(Number), + position: "top-right", + style: expect.any(Object), + }), + ); + + // Get the actual duration that was passed + const callArgs = ( + toast.error as unknown as { mock: { calls: unknown[][] } } + ).mock.calls[0][1] as { duration: number }; + const actualDuration = callArgs.duration; + + // For a long message, duration should be more than the minimum 4000ms + expect(actualDuration).toBeGreaterThan(4000); + // But should not exceed the maximum 10000ms + expect(actualDuration).toBeLessThanOrEqual(10000); + }); + }); +}); diff --git a/frontend/src/utils/__tests__/toast-duration.test.ts b/frontend/src/utils/__tests__/toast-duration.test.ts new file mode 100644 index 0000000000..3b5ffa8b69 --- /dev/null +++ b/frontend/src/utils/__tests__/toast-duration.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { calculateToastDuration } from "../toast-duration"; + +describe("calculateToastDuration", () => { + it("should return minimum duration for short messages", () => { + const shortMessage = "OK"; + const duration = calculateToastDuration(shortMessage, 5000); + expect(duration).toBe(5000); + }); + + it("should return minimum duration for messages that calculate below minimum", () => { + const shortMessage = "Settings saved"; + const duration = calculateToastDuration(shortMessage, 5000); + expect(duration).toBe(5000); + }); + + it("should calculate longer duration for long messages", () => { + const longMessage = + "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes."; + const duration = calculateToastDuration(longMessage, 5000); + expect(duration).toBeGreaterThan(5000); + expect(duration).toBeLessThanOrEqual(10000); + }); + + it("should respect maximum duration cap", () => { + const veryLongMessage = "A".repeat(10000); // Very long message + const duration = calculateToastDuration(veryLongMessage, 5000, 10000); + expect(duration).toBe(10000); + }); + + it("should use custom minimum and maximum durations", () => { + const message = "Test message"; + const customMin = 3000; + const customMax = 8000; + const duration = calculateToastDuration(message, customMin, customMax); + expect(duration).toBeGreaterThanOrEqual(customMin); + expect(duration).toBeLessThanOrEqual(customMax); + }); + + it("should calculate duration based on reading speed", () => { + // Test with a message that should take exactly the calculated time + // At 200 WPM (1000 chars/min), 60 chars should take 3.6 seconds + // With 1.5x buffer, that's 5.4 seconds + const message = "This is a test message that contains exactly sixty chars."; + expect(message.length).toBe(57); // Close to 60 chars + + const duration = calculateToastDuration(message, 0, 20000); // No min/max constraints + + // Should be around 5.4 seconds (5400ms) for 57 characters + expect(duration).toBeGreaterThan(5000); + expect(duration).toBeLessThan(6000); + }); +}); diff --git a/frontend/src/utils/custom-toast-handlers.tsx b/frontend/src/utils/custom-toast-handlers.tsx index 502d8263b0..d6aeaf0420 100644 --- a/frontend/src/utils/custom-toast-handlers.tsx +++ b/frontend/src/utils/custom-toast-handlers.tsx @@ -1,5 +1,6 @@ import { CSSProperties } from "react"; import toast, { ToastOptions } from "react-hot-toast"; +import { calculateToastDuration } from "./toast-duration"; const TOAST_STYLE: CSSProperties = { background: "#454545", @@ -14,9 +15,11 @@ const TOAST_OPTIONS: ToastOptions = { }; export const displayErrorToast = (error: string) => { - toast.error(error, TOAST_OPTIONS); + const duration = calculateToastDuration(error, 4000); + toast.error(error, { ...TOAST_OPTIONS, duration }); }; export const displaySuccessToast = (message: string) => { - toast.success(message, TOAST_OPTIONS); + const duration = calculateToastDuration(message, 5000); + toast.success(message, { ...TOAST_OPTIONS, duration }); }; diff --git a/frontend/src/utils/toast-duration.ts b/frontend/src/utils/toast-duration.ts new file mode 100644 index 0000000000..1c0b08c883 --- /dev/null +++ b/frontend/src/utils/toast-duration.ts @@ -0,0 +1,27 @@ +/** + * Calculate toast duration based on message length + * @param message - The message to display + * @param minDuration - Minimum duration in milliseconds (default: 5000 for success, 4000 for error) + * @param maxDuration - Maximum duration in milliseconds (default: 10000) + * @returns Duration in milliseconds + */ +export const calculateToastDuration = ( + message: string, + minDuration: number = 5000, + maxDuration: number = 10000, +): number => { + // Calculate duration based on reading speed (average 200 words per minute) + // Assuming average word length of 5 characters + const wordsPerMinute = 200; + const charactersPerMinute = wordsPerMinute * 5; + const charactersPerSecond = charactersPerMinute / 60; + + // Calculate time needed to read the message + const readingTimeMs = (message.length / charactersPerSecond) * 1000; + + // Add some buffer time (50% extra) for processing + const durationWithBuffer = readingTimeMs * 1.5; + + // Ensure duration is within min/max bounds + return Math.min(Math.max(durationWithBuffer, minDuration), maxDuration); +}; diff --git a/frontend/src/utils/toast.tsx b/frontend/src/utils/toast.tsx index e75d018d39..a80c7f0e5d 100644 --- a/frontend/src/utils/toast.tsx +++ b/frontend/src/utils/toast.tsx @@ -1,4 +1,5 @@ import toast from "react-hot-toast"; +import { calculateToastDuration } from "./toast-duration"; const idMap = new Map(); @@ -37,7 +38,7 @@ export default { toast(msg, { position: "bottom-right", className: "bg-tertiary", - + duration: calculateToastDuration(msg, 5000), icon: "⚙️", style: { background: "#333",