@@ -454,6 +454,14 @@ function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: T
454454interface 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