From 70752a0675926b19dc6206279064a3fed8d5bfc9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 18:54:24 -0800 Subject: [PATCH] Auto-dismiss thread toasts after 10s of visible time - Add `dismissAfterVisibleMs` support to thread toasts - Pause/resume countdown based on tab visibility and window focus - Set Git action result toasts to dismiss after 10s when visible --- apps/web/src/components/GitActionsControl.tsx | 5 +- apps/web/src/components/ui/toast.tsx | 95 +++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 83c74e5f5807..e30ff30dfcdf 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -312,7 +312,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp title: resultToast.title, description: resultToast.description, timeout: 0, - data: threadToastData, + data: { + ...threadToastData, + dismissAfterVisibleMs: 10_000, + }, ...(shouldOfferPushCta ? { actionProps: { diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 6c9614a91a00..19f9bb6b6190 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,6 +1,7 @@ "use client"; import { Toast } from "@base-ui/react/toast"; +import { useEffect } from "react"; import { CircleAlertIcon, CircleCheckIcon, @@ -16,10 +17,13 @@ import { useStore } from "~/store"; type ThreadToastData = { threadId?: string | null; tooltipStyle?: boolean; + dismissAfterVisibleMs?: number; }; const toastManager = Toast.createToastManager(); const anchoredToastManager = Toast.createToastManager(); +type ToastId = ReturnType; +const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, @@ -50,6 +54,84 @@ function shouldRenderForActiveThread( return toastThreadId === activeThreadId; } +function ThreadToastVisibleAutoDismiss({ + toastId, + dismissAfterVisibleMs, +}: { + toastId: ToastId; + dismissAfterVisibleMs: number | undefined; +}) { + useEffect(() => { + if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return; + if (typeof window === "undefined" || typeof document === "undefined") return; + + let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs; + let startedAtMs: number | null = null; + let timeoutId: number | null = null; + let closed = false; + + const clearTimer = () => { + if (timeoutId === null) return; + window.clearTimeout(timeoutId); + timeoutId = null; + }; + + const closeToast = () => { + if (closed) return; + closed = true; + threadToastVisibleTimeoutRemainingMs.delete(toastId); + toastManager.close(toastId); + }; + + const pause = () => { + if (startedAtMs === null) return; + remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs)); + startedAtMs = null; + clearTimer(); + threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs); + }; + + const start = () => { + if (closed || startedAtMs !== null) return; + if (remainingMs <= 0) { + closeToast(); + return; + } + startedAtMs = Date.now(); + clearTimer(); + timeoutId = window.setTimeout(() => { + remainingMs = 0; + startedAtMs = null; + closeToast(); + }, remainingMs); + }; + + const syncTimer = () => { + const shouldRun = document.visibilityState === "visible" && document.hasFocus(); + if (shouldRun) { + start(); + return; + } + pause(); + }; + + syncTimer(); + document.addEventListener("visibilitychange", syncTimer); + window.addEventListener("focus", syncTimer); + window.addEventListener("blur", syncTimer); + + return () => { + document.removeEventListener("visibilitychange", syncTimer); + window.removeEventListener("focus", syncTimer); + window.removeEventListener("blur", syncTimer); + pause(); + clearTimer(); + }; + }, [dismissAfterVisibleMs, toastId]); + + return null; +} + function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) { return ( @@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) { const activeThreadId = state.activeThreadId; const isTop = position.startsWith("top"); + useEffect(() => { + const activeToastIds = new Set(toasts.map((toast) => toast.id)); + for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) { + if (!activeToastIds.has(toastId)) { + threadToastVisibleTimeoutRemainingMs.delete(toastId); + } + } + }, [toasts]); + return ( +
{Icon && (