'use client' import { type ComponentType, createContext, type ReactNode, type SVGProps, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react' import { generateId } from '@sim/utils/id' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' import { usePathname } from 'next/navigation' import { createPortal } from 'react-dom' import { Bell } from '../../icons/bell' import { CircleAlert } from '../../icons/circle-alert' import { CircleCheck } from '../../icons/circle-check' import { CircleInfo } from '../../icons/circle-info' import { TriangleAlert } from '../../icons/triangle-alert' import { X } from '../../icons/x' import { cn } from '../../lib/cn' import { Button } from '../button/button' import { Chip } from '../chip/chip' import { chipContentIconClass, chipFilledFillTokens } from '../chip/chip-chrome' const AUTO_DISMISS_MS = 5000 /** Card width; tracks the workflow-panel inset on narrow viewports. */ const TOAST_WIDTH = 'min(100vw - 2rem, 280px)' /** Most toasts kept alive at once; older arrivals are evicted. */ const STACK_LIMIT = 3 /** Per-depth lift and shrink that make collapsed cards peek above the front one. */ const COLLAPSED_OFFSET_PX = 13 const COLLAPSED_SCALE_STEP = 0.05 /** Vertical gap between cards once the stack is expanded. */ const EXPAND_GAP_PX = 8 /** Fallback card height used for the expanded fan-out before a toast is measured. */ const ESTIMATED_TOAST_HEIGHT = 56 /** Card border box, added to the measured content so the clamped `
  • ` height matches. */ const CARD_BORDER_PX = 2 /** Shared expo-out easing so every card in the stack reshuffles with identical timing. */ const TOAST_EASE = [0.22, 1, 0.36, 1] as const const STACK_DURATION = 0.4 const RESIZE_DURATION = 0.3 /** Single-line cards use a tighter corner radius; taller cards keep the concentric 16px. */ const COMPACT_CARD_HEIGHT_PX = 46 const COMPACT_RADIUS_PX = 12 const CONCENTRIC_RADIUS_PX = 16 type ToastVariant = 'default' | 'info' | 'success' | 'warning' | 'error' /** Leading icon per variant; the shape alone signals intent, tinted with the canonical chip icon color. */ const VARIANT_ICON: Record>> = { default: Bell, info: CircleInfo, success: CircleCheck, warning: TriangleAlert, error: CircleAlert, } interface ToastAction { label: string onClick: () => void } interface ToastData { id: string message: string description?: string variant: ToastVariant action?: ToastAction duration: number persistAcrossRoutes: boolean } type ToastInput = { message: string description?: string variant?: ToastVariant action?: ToastAction duration?: number /** * Keep the toast across navigation. The stack is otherwise cleared on every * route change (route-scoped notifications shouldn't trail the user); set * this for global, ongoing-state toasts like a connection/reconnect status. * @default false */ persistAcrossRoutes?: boolean } type ToastFn = { (input: ToastInput): string success: (message: string, options?: Omit) => string error: (message: string, options?: Omit) => string warning: (message: string, options?: Omit) => string info: (message: string, options?: Omit) => string /** Dismisses a single toast by id. */ dismiss: (id: string) => void /** Dismisses every visible toast. */ dismissAll: () => void } interface ToastContextValue { toast: ToastFn dismiss: (id: string) => void dismissAll: () => void } const ToastContext = createContext(null) let globalToast: ToastFn | null = null let globalDismiss: ((id: string) => void) | null = null let globalDismissAll: (() => void) | null = null function createToastFn(add: (input: ToastInput) => string): ToastFn { const fn = ((input: ToastInput) => add(input)) as ToastFn fn.success = (message, options) => add({ ...options, message, variant: 'success' }) fn.error = (message, options) => add({ ...options, message, variant: 'error' }) fn.warning = (message, options) => add({ ...options, message, variant: 'warning' }) fn.info = (message, options) => add({ ...options, message, variant: 'info' }) fn.dismiss = (id) => globalDismiss?.(id) fn.dismissAll = () => globalDismissAll?.() return fn } /** * Imperative toast. Requires a mounted ``. A toast carrying an * `action` persists until dismissed unless an explicit `duration` is passed. * * @example * ```tsx * toast.error('Upload failed', { description: 'Network timed out' }) * toast.success('Saved', { action: { label: 'View', onClick: () => router.push('/x') } }) * ``` */ export const toast: ToastFn = createToastFn((input) => { if (!globalToast) { throw new Error('toast() called before mounted') } return globalToast(input) }) /** Hook to access the toast function and dismiss helpers from context. */ export function useToast() { const ctx = useContext(ToastContext) if (!ctx) throw new Error('useToast must be used within ') return ctx } interface ToastGeometry { /** Vertical lift from the bottom anchor (negative = upward). */ y: number /** Depth shrink; `1` when expanded or for the front card. */ scale: number /** Rendered height; collapsed back cards are clamped to the front card's height. */ height: number /** Paint order — the front card sits on top. */ zIndex: number } interface ToastItemProps { toast: ToastData geometry: ToastGeometry reduceMotion: boolean onDismiss: (id: string) => void onMeasure: (id: string, height: number) => void } interface RevealTextProps { text: string /** Whether the parent card is hovered — the trigger for revealing hidden lines. */ expanded: boolean /** Lines kept visible before truncation. */ clampLines: number /** Line height in px; must match the text leading so the head/tail split lands on a line boundary. */ lineHeightPx: number /** Optional inline icon; included in head and tail so wrapping stays identical. */ leadingIcon?: ReactNode className?: string reduceMotion: boolean } /** * Truncated text that reveals its hidden lines on hover: the head shows the * first `clampLines` lines, and when hovered (and actually overflowing) the * remaining lines mount below and blur in as one continuous block. Text that * fits within the clamp is never truncated and the card never grows for it. */ function RevealText({ text, expanded, clampLines, lineHeightPx, leadingIcon, className, reduceMotion, }: RevealTextProps) { const headRef = useRef(null) const [truncated, setTruncated] = useState(false) const clampHeight = clampLines * lineHeightPx useLayoutEffect(() => { const el = headRef.current if (!el) return const check = () => setTruncated(el.scrollHeight - el.clientHeight > 1) check() const observer = new ResizeObserver(check) observer.observe(el) return () => observer.disconnect() }, [text]) const open = expanded && truncated const fadeStart = Math.max(0, clampHeight - lineHeightPx * 1.5) const collapsedMask = `linear-gradient(to bottom, #000 ${fadeStart}px, transparent ${clampHeight}px)` return (
    {leadingIcon} {text}
    {open ? ( {leadingIcon} {text} ) : null}
    ) } function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: ToastItemProps) { const contentRef = useRef(null) const [hovered, setHovered] = useState(false) const Icon = VARIANT_ICON[t.variant] /** * Report the natural height — measured on the unconstrained inner content so * the outer `
  • ` clamp can't feed back — so the provider lays the fan and * collapsed clamp against real heights. `useLayoutEffect` avoids a paint jump. */ useLayoutEffect(() => { const el = contentRef.current if (!el) return const report = () => onMeasure(t.id, el.offsetHeight + CARD_BORDER_PX) report() const observer = new ResizeObserver(report) observer.observe(el) return () => observer.disconnect() }, [t.id, onMeasure]) const dismiss = useCallback(() => onDismiss(t.id), [onDismiss, t.id]) const { y, scale, height, zIndex } = geometry const cornerRadius = height <= COMPACT_CARD_HEIGHT_PX ? COMPACT_RADIUS_PX : CONCENTRIC_RADIUS_PX /** One fixed-duration tween so all cards reshuffle in unison; height tracks content instantly. */ const transition = reduceMotion ? { duration: 0 } : { duration: STACK_DURATION, ease: TOAST_EASE, height: { duration: 0 }, } return ( setHovered(true)} onMouseLeave={() => setHovered(false)} initial={reduceMotion ? false : { opacity: 0, y: height }} animate={{ opacity: 1, y, scale, height }} exit={{ opacity: 0, scale: 0.95, transition: reduceMotion ? { duration: 0 } : { duration: 0.15, ease: 'easeIn' }, }} transition={transition} style={{ zIndex, transformOrigin: 'bottom', width: TOAST_WIDTH, borderRadius: cornerRadius }} className='pointer-events-auto absolute right-0 bottom-0 m-0 overflow-hidden border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]' >
    } className='text-[var(--text-body)] text-sm leading-5' reduceMotion={reduceMotion} />
    {t.description ? ( ) : null} {t.action ? ( { t.action!.onClick() dismiss() }} className={cn('justify-center', chipFilledFillTokens)} > {t.action.label} ) : null}
    ) } /** * Toast container, mounted once in the root layout. Toasts pile bottom-right as * a collapsed stack that fans open on hover or keyboard focus, mirroring the * Sonner / Base-UI interaction; hovering pauses auto-dismiss. On workflow pages * the stack is inset by the panel and terminal, and it clears on navigation. * * @example * ```tsx * * ``` */ export function ToastProvider({ children }: { children?: ReactNode }) { const pathname = usePathname() const reduceMotion = useReducedMotion() ?? false /** On the workflow editor (`/w/[id]` and the `/w` index) the stack insets by `--panel-width` / `--terminal-height` to clear the panel and terminal. */ const isWorkflowPage = pathname ? /\/w(\/|$)/.test(pathname) : false const [toasts, setToasts] = useState([]) const [heights, setHeights] = useState>({}) const [expanded, setExpanded] = useState(false) const [mounted, setMounted] = useState(false) const timersRef = useRef(new Map>()) useEffect(() => { setMounted(true) }, []) /** * Reset the hover-expanded flag whenever the stack empties. The hover wrapper * unmounts without firing mouse-leave when the last toast goes (dismiss / clear * / navigation), so without this `expanded` could stay `true` and stop the next * toasts from auto-dismissing. */ useEffect(() => { if (toasts.length === 0) setExpanded(false) }, [toasts.length]) /** * Adds a toast. Actionable toasts persist (`duration: 0`) unless an explicit * `duration` is given. When the stack exceeds `STACK_LIMIT` the oldest * auto-dismissable toast is evicted first, so a persistent (actionable) toast * isn't silently dropped — only an all-persistent overflow evicts the oldest. */ const addToast = useCallback((input: ToastInput): string => { const id = generateId() const data: ToastData = { id, message: input.message, description: input.description, variant: input.variant ?? 'default', action: input.action, duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS), persistAcrossRoutes: input.persistAcrossRoutes ?? false, } setToasts((prev) => { const next = [...prev, data] if (next.length <= STACK_LIMIT) return next const evictIndex = next.findIndex((t) => t.duration > 0) next.splice(evictIndex === -1 ? 0 : evictIndex, 1) return next }) return id }, []) const dismissToast = useCallback((id: string) => { const timer = timersRef.current.get(id) if (timer) { clearTimeout(timer) timersRef.current.delete(id) } setToasts((prev) => prev.filter((t) => t.id !== id)) setHeights((prev) => { if (!(id in prev)) return prev const next = { ...prev } delete next[id] return next }) }, []) const dismissAllToasts = useCallback(() => { for (const timer of timersRef.current.values()) clearTimeout(timer) timersRef.current.clear() setToasts([]) setHeights({}) }, []) /** * Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` — * global, ongoing-state notifications like the connection status — survive, * everything else (page-scoped notifications) is cleared on navigation. */ const dismissRouteScopedToasts = useCallback(() => { setToasts((prev) => { const kept = prev.filter((t) => t.persistAcrossRoutes) if (kept.length === prev.length) return prev for (const t of prev) { if (t.persistAcrossRoutes) continue const timer = timersRef.current.get(t.id) if (timer) { clearTimeout(timer) timersRef.current.delete(t.id) } } return kept }) }, []) const measureToast = useCallback((id: string, height: number) => { setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height })) }, []) /** Drop measured heights for toasts evicted by `slice(-STACK_LIMIT)` (single dismissal prunes its own entry). */ useEffect(() => { setHeights((prev) => { const live = new Set(toasts.map((t) => t.id)) const stale = Object.keys(prev).filter((id) => !live.has(id)) if (stale.length === 0) return prev const next = { ...prev } for (const id of stale) delete next[id] return next }) }, [toasts]) /** * Per-toast auto-dismiss timers. Each timed toast runs its own timer so it * expires independently regardless of how many toasts are stacked; persistent * toasts (`duration <= 0`) never get a timer, and hovering (`expanded`) holds * every timer so a toast can't be cleared mid-read. */ useEffect(() => { const timers = timersRef.current if (toasts.length === 0 || expanded) { for (const timer of timers.values()) clearTimeout(timer) timers.clear() return } for (const t of toasts) { if (t.duration <= 0 || timers.has(t.id)) continue timers.set( t.id, setTimeout(() => { timers.delete(t.id) dismissToast(t.id) }, t.duration) ) } for (const [id, timer] of timers) { if (!toasts.some((t) => t.id === id)) { clearTimeout(timer) timers.delete(id) } } }, [toasts, expanded, dismissToast]) useEffect(() => { const timers = timersRef.current return () => { for (const timer of timers.values()) clearTimeout(timer) } }, []) /** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */ useEffect(() => { dismissRouteScopedToasts() }, [pathname, dismissRouteScopedToasts]) /** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */ const toastFn = useRef(createToastFn(addToast)) useEffect(() => { const fn = toastFn.current globalToast = fn globalDismiss = dismissToast globalDismissAll = dismissAllToasts return () => { if (globalToast === fn) globalToast = null if (globalDismiss === dismissToast) globalDismiss = null if (globalDismissAll === dismissAllToasts) globalDismissAll = null } }, [dismissToast, dismissAllToasts]) const ctx = useMemo( () => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll: dismissAllToasts }), [dismissToast, dismissAllToasts] ) const ordered = [...toasts].reverse() const frontHeight = heights[ordered[0]?.id ?? ''] ?? ESTIMATED_TOAST_HEIGHT let cumulative = 0 const layout = ordered.map((toast, index) => { const naturalHeight = heights[toast.id] ?? ESTIMATED_TOAST_HEIGHT const offsetBefore = cumulative cumulative += naturalHeight + EXPAND_GAP_PX const geometry: ToastGeometry = { y: expanded ? -offsetBefore : -index * COLLAPSED_OFFSET_PX, scale: expanded ? 1 : 1 - index * COLLAPSED_SCALE_STEP, height: expanded || index === 0 ? naturalHeight : frontHeight, zIndex: ordered.length - index, } return { toast, geometry } }) const collapsedHeight = frontHeight + Math.max(0, Math.min(ordered.length, STACK_LIMIT) - 1) * COLLAPSED_OFFSET_PX const expandedHeight = cumulative > 0 ? cumulative - EXPAND_GAP_PX : frontHeight const containerHeight = expanded ? expandedHeight : collapsedHeight return ( {children} {mounted ? createPortal( {toasts.length > 0 ? (
    setExpanded(true)} onMouseLeave={() => setExpanded(false)} onFocusCapture={() => setExpanded(true)} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { setExpanded(false) } }} className='absolute inset-0' > {layout.map(({ toast, geometry }) => ( ))}
    ) : null}
    , document.body ) : null}
    ) }