Skip to content

Commit 59a1b9b

Browse files
waleedlatif1claude
andcommitted
fix(emcn/toast): keep persistent toasts alive in a stack; prune stale heights
H1 (functional): a stack of 2+ toasts arms the StackDismiss ring, whose 6s countdown called dismissAllToasts() — wiping persistent (duration<=0) actionable toasts like 'Fix in Copilot' before the user could react. Add an `autoDismiss` flag (false when any toast is persistent) that suppresses the auto-countdown while keeping the manual dismiss-all button. H2 (memory): stack-limit eviction (slice) dropped the oldest toast without clearing its `heights` entry, leaking entries over a session. Reconcile heights to live toast ids, mirroring the timer effect's stale-entry cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4681b87 commit 59a1b9b

1 file changed

Lines changed: 50 additions & 3 deletions

File tree

  • apps/sim/components/emcn/components/toast

apps/sim/components/emcn/components/toast/toast.tsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,14 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
454454
interface StackDismissProps {
455455
/** When held (stack hovered), the countdown pauses so it can't dismiss mid-read. */
456456
paused: boolean
457+
/**
458+
* Whether the ring auto-fires. `false` when the stack holds a persistent
459+
* toast (an actionable / `duration <= 0` toast that must not vanish on its
460+
* own) — the control still renders and dismisses on click, but the countdown
461+
* never runs, so a "Fix in Copilot" action can't be cleared out from under
462+
* the user just because a second toast arrived.
463+
*/
464+
autoDismiss: boolean
457465
reduceMotion: boolean
458466
/** Changes whenever a new toast arrives; restarts the countdown from zero. */
459467
resetKey: string
@@ -468,7 +476,13 @@ interface StackDismissProps {
468476
* immediately. It enters with a spring "pop" (overshoot-and-settle) so the
469477
* secondary control arrives with a little life without stealing focus.
470478
*/
471-
function StackDismiss({ paused, reduceMotion, resetKey, onDismiss }: StackDismissProps) {
479+
function StackDismiss({
480+
paused,
481+
autoDismiss,
482+
reduceMotion,
483+
resetKey,
484+
onDismiss,
485+
}: StackDismissProps) {
472486
const progress = useMotionValue(0)
473487
const onDismissRef = useRef(onDismiss)
474488
const controlsRef = useRef<ReturnType<typeof animate> | null>(null)
@@ -486,9 +500,15 @@ function StackDismiss({ paused, reduceMotion, resetKey, onDismiss }: StackDismis
486500

487501
// Restart the countdown from zero whenever a new toast arrives (`resetKey`
488502
// changes), so every fresh arrival gives the whole stack the full window
489-
// again rather than inheriting the older, already-elapsed timer.
503+
// again rather than inheriting the older, already-elapsed timer. When
504+
// `autoDismiss` is false (a persistent toast is in the stack) the ring stays
505+
// empty and never fires — only the click handler can dismiss.
490506
useEffect(() => {
491507
progress.set(0)
508+
if (!autoDismiss) {
509+
controlsRef.current = null
510+
return
511+
}
492512
const controls = animate(progress, 1, {
493513
duration: STACK_DISMISS_MS / 1000,
494514
ease: 'linear',
@@ -497,7 +517,7 @@ function StackDismiss({ paused, reduceMotion, resetKey, onDismiss }: StackDismis
497517
controlsRef.current = controls
498518
if (heldRef.current) controls.pause()
499519
return () => controls.stop()
500-
}, [progress, resetKey])
520+
}, [progress, resetKey, autoDismiss])
501521

502522
useEffect(() => {
503523
const controls = controlsRef.current
@@ -631,6 +651,24 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
631651
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }))
632652
}, [])
633653

654+
/**
655+
* Drop measured heights for toasts that are no longer in the stack. Single
656+
* dismissal prunes its own entry, but stack-limit eviction (`addToast`'s
657+
* `slice(-STACK_LIMIT)`) drops the oldest toast without touching `heights` —
658+
* so reconcile here, mirroring the timer effect's stale-entry cleanup, to
659+
* keep the map from growing across a long session.
660+
*/
661+
useEffect(() => {
662+
setHeights((prev) => {
663+
const live = new Set(toasts.map((t) => t.id))
664+
const stale = Object.keys(prev).filter((id) => !live.has(id))
665+
if (stale.length === 0) return prev
666+
const next = { ...prev }
667+
for (const id of stale) delete next[id]
668+
return next
669+
})
670+
}, [toasts])
671+
634672
/**
635673
* Per-toast auto-dismiss timers, used only for a lone toast. Hovering
636674
* (`expanded`) holds them, and once the stack reaches the dismiss-all
@@ -705,6 +743,14 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
705743
[dismissToast, dismissAllToasts]
706744
)
707745

746+
/**
747+
* A persistent toast (`duration <= 0` — i.e. an actionable toast that must
748+
* wait for the user) pins the stack: the dismiss-all ring still renders but
749+
* its auto-countdown is suppressed so the action can't be cleared out from
750+
* under the user when a second toast arrives.
751+
*/
752+
const hasPersistentToast = toasts.some((t) => t.duration <= 0)
753+
708754
/** Front-first order: `ordered[0]` is the newest card. */
709755
const ordered = [...toasts].reverse()
710756
const frontHeight = heights[ordered[0]?.id ?? ''] ?? ESTIMATED_TOAST_HEIGHT
@@ -748,6 +794,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
748794
<StackDismiss
749795
key='dismiss-all'
750796
paused={expanded}
797+
autoDismiss={!hasPersistentToast}
751798
reduceMotion={reduceMotion}
752799
resetKey={toasts[toasts.length - 1]?.id ?? ''}
753800
onDismiss={dismissAllToasts}

0 commit comments

Comments
 (0)