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...@@ -312,7 +312,10 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp
312 title: resultToast.title,312 title: resultToast.title,
313 description: resultToast.description,313 description: resultToast.description,
314 timeout: 0,314 timeout: 0,
315 data: threadToastData,315 data: {
316 ...threadToastData,
317 dismissAfterVisibleMs: 10_000,
318 },
316 ...(shouldOfferPushCta319 ...(shouldOfferPushCta
317 ? {320 ? {
318 actionProps: {321 actionProps: {
apps/web/src/components/ui/toast.tsx+95
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1"use client";1"use client";
22
3import { Toast } from "@base-ui/react/toast";3import { Toast } from "@base-ui/react/toast";
4import { useEffect } from "react";
4import {5import {
5 CircleAlertIcon,6 CircleAlertIcon,
6 CircleCheckIcon,7 CircleCheckIcon,
...@@ -16,10 +17,13 @@ import { useStore } from "~/store";...@@ -16,10 +17,13 @@ import { useStore } from "~/store";
16type ThreadToastData = {17type ThreadToastData = {
17 threadId?: string | null;18 threadId?: string | null;
18 tooltipStyle?: boolean;19 tooltipStyle?: boolean;
20 dismissAfterVisibleMs?: number;
19};21};
2022
21const toastManager = Toast.createToastManager<ThreadToastData>();23const toastManager = Toast.createToastManager<ThreadToastData>();
22const anchoredToastManager = Toast.createToastManager<ThreadToastData>();24const anchoredToastManager = Toast.createToastManager<ThreadToastData>();
25type ToastId = ReturnType<typeof toastManager.add>;
26const threadToastVisibleTimeoutRemainingMs = new Map<ToastId, number>();
2327
24const TOAST_ICONS = {28const TOAST_ICONS = {
25 error: CircleAlertIcon,29 error: CircleAlertIcon,
...@@ -50,6 +54,84 @@ function shouldRenderForActiveThread(...@@ -50,6 +54,84 @@ function shouldRenderForActiveThread(
50 return toastThreadId === activeThreadId;54 return toastThreadId === activeThreadId;
51}55}
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
53function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {135function ToastProvider({ children, position = "top-right", ...props }: ToastProviderProps) {
54 return (136 return (
55 <Toast.Provider toastManager={toastManager} {...props}>137 <Toast.Provider toastManager={toastManager} {...props}>
...@@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -65,6 +147,15 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
65 const activeThreadId = state.activeThreadId;147 const activeThreadId = state.activeThreadId;
66 const isTop = position.startsWith("top");148 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
68 return (159 return (
69 <Toast.Portal data-slot="toast-portal">160 <Toast.Portal data-slot="toast-portal">
70 <Toast.Viewport161 <Toast.Viewport
...@@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {...@@ -140,6 +231,10 @@ function Toasts({ position = "top-right" }: { position: ToastPosition }) {
140 }231 }
141 toast={toast}232 toast={toast}
142 >233 >
234 <ThreadToastVisibleAutoDismiss
235 dismissAfterVisibleMs={toast.data?.dismissAfterVisibleMs}
236 toastId={toast.id}
237 />
143 <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">238 <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">
144 <div className="flex gap-2">239 <div className="flex gap-2">
145 {Icon && (240 {Icon && (