diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index dba09ad..0c31872 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -129,6 +129,20 @@ let items: Notification[] = load(); let listeners: Array<() => void> = []; let counter = items.reduce((m, n) => Math.max(m, n.id), 0) + 1; +// Per-toast auto-dismiss timers. Keyed by id so re-arming (a coalesced repeat re-surfacing the same +// toast) can CLEAR the previous timer first — otherwise the old one still fires at the original +// deadline and dismisses the re-surfaced toast early (C-3.25). +const dismissTimers = new Map>(); + +function armDismiss(id: number, duration: number): void { + const existing = dismissTimers.get(id); + if (existing) clearTimeout(existing); + dismissTimers.set( + id, + setTimeout(() => dismiss(id), duration), + ); +} + // Cached derived snapshots — useSyncExternalStore needs stable references between // emits, so we recompute these only when `items` changes. let cachedActive: Notification[] = []; @@ -243,7 +257,7 @@ export function notify(input: NotifyInput): number { : n, ); emit(); - if (duration) setTimeout(() => dismiss(prior.id), duration); + if (duration) armDismiss(prior.id, duration); // Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet. return prior.id; } @@ -275,12 +289,17 @@ export function notify(input: NotifyInput): number { ) { beep(); } - if (duration) setTimeout(() => dismiss(id), duration); + if (duration) armDismiss(id, duration); return id; } /** Close the transient toast surface; the entry stays in the center's history. */ export function dismiss(id: number): void { + const t = dismissTimers.get(id); + if (t) { + clearTimeout(t); + dismissTimers.delete(id); + } items = items.map((n) => (n.id === id ? { ...n, dismissed: true } : n)); emit(); }