authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 18:54:24-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 19:16:49-08:00
log38639010859b760c1770c717f737e5e725127f46
treedde0eebea7fedd48e52b1b41925315a10c69aa70
parentf864ef189d5def4cb2ae3f34cc461f13b3fc5a00

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

2 files changed, 99 insertions(+), 1 deletions(-)

apps/web/src/components/GitActionsControl.tsx+4-1
......@@ -312,7 +312,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
312312 title: resultToast.title,
313313 description: resultToast.description,
314314 timeout: 0,
315 data: threadToastData,
315 data: {
316 ...threadToastData,
317 dismissAfterVisibleMs: 10_000,
318 },
316319 ...(shouldOfferPushCta
317320 ? {
318321 actionProps: {
apps/web/src/components/ui/toast.tsx+95
......@@ -1,6 +1,7 @@
11"use client";
22
33import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";
45import {
56 CircleAlertIcon,
67 CircleCheckIcon,
......@@ -16,10 +17,13 @@ import { useStore } from "~/store";
1617type ThreadToastData = {
1718 threadId?: string | null;
1819 tooltipStyle?: boolean;
20 dismissAfterVisibleMs?: number;
1921};
2022
2123const toastManager = Toast.createToastManager<ThreadToastData>();
2224const anchoredToastManager = Toast.createToastManager<ThreadToastData>();
25type ToastId = ReturnType<typeof toastManager.add>;
26const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
2327
2428const TOAST_ICONS = {
2529 error: CircleAlertIcon,
......@@ -50,6 +54,84 @@ function shouldRenderForActiveThread(
5054 return toastThreadId === activeThreadId;
5155}
5256
57function ThreadToastVisibleAutoDismiss({
58 toastId,
59 dismissAfterVisibleMs,
60}: {
61 toastId: ToastId;
62 dismissAfterVisibleMs: number | undefined;
63}) {
64 useEffect(() => {
65 if (!dismissAfterVisibleMs || dismissAfterVisibleMs <= 0) return;
66 if (typeof window === "undefined" || typeof document === "undefined") return;
67
68 let remainingMs = threadToastVisibleTimeoutRemainingMs.get(toastId) ?? dismissAfterVisibleMs;
69 let startedAtMs: number | null = null;
70 let timeoutId: number | null = null;
71 let closed = false;
72
73 const clearTimer = () => {
74 if (timeoutId === null) return;
75 window.clearTimeout(timeoutId);
76 timeoutId = null;
77 };
78
79 const closeToast = () => {
80 if (closed) return;
81 closed = true;
82 threadToastVisibleTimeoutRemainingMs.delete(toastId);
83 toastManager.close(toastId);
84 };
85
86 const pause = () => {
87 if (startedAtMs === null) return;
88 remainingMs = Math.max(0, remainingMs - (Date.now() - startedAtMs));
89 startedAtMs = null;
90 clearTimer();
91 threadToastVisibleTimeoutRemainingMs.set(toastId, remainingMs);
92 };
93
94 const start = () => {
95 if (closed || startedAtMs !== null) return;
96 if (remainingMs <= 0) {
97 closeToast();
98 return;
99 }
100 startedAtMs = Date.now();
101 clearTimer();
102 timeoutId = window.setTimeout(() => {
103 remainingMs = 0;
104 startedAtMs = null;
105 closeToast();
106 }, remainingMs);
107 };
108
109 const syncTimer = () => {
110 const shouldRun = document.visibilityState === "visible" && document.hasFocus();
111 if (shouldRun) {
112 start();
113 return;
114 }
115 pause();
116 };
117
118 syncTimer();
119 document.addEventListener("visibilitychange", syncTimer);
120 window.addEventListener("focus", syncTimer);
121 window.addEventListener("blur", syncTimer);
122
123 return () => {
124 document.removeEventListener("visibilitychange", syncTimer);
125 window.removeEventListener("focus", syncTimer);
126 window.removeEventListener("blur", syncTimer);
127 pause();
128 clearTimer();
129 };
130 }, [dismissAfterVisibleMs, toastId]);
131
132 return null;
133}
134
53135function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {
54136 return (
55137 <Toast.Provider toastManager={toastManager} {...props}>
......@@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
65147 const activeThreadId = state.activeThreadId;
66148 const isTop = position.startsWith("top");
67149
150 useEffect(() => {
151 const activeToastIds = new Set(toasts.map((toast) => toast.id));
152 for (const toastId of threadToastVisibleTimeoutRemainingMs.keys()) {
153 if (!activeToastIds.has(toastId)) {
154 threadToastVisibleTimeoutRemainingMs.delete(toastId);
155 }
156 }
157 }, [toasts]);
158
68159 return (
69160 <Toast.Portal data-slot="toast-portal">
70161 <Toast.Viewport
......@@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
140231 }
141232 toast={toast}
142233 >
234 <ThreadToastVisibleAutoDismiss
235 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
236 toastId={toast.id}
237 />
143238 <Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">
144239 <div className="flex gap-2">
145240 {Icon && (