chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,640 @@
|
||||
'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 `<li>` 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<ToastVariant, ComponentType<SVGProps<SVGSVGElement>>> = {
|
||||
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<ToastInput, 'message' | 'variant'>) => string
|
||||
error: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
||||
warning: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => string
|
||||
info: (message: string, options?: Omit<ToastInput, 'message' | 'variant'>) => 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<ToastContextValue | null>(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 `<ToastProvider>`. 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 <ToastProvider> 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 <ToastProvider>')
|
||||
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<HTMLDivElement>(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 (
|
||||
<div>
|
||||
<div
|
||||
ref={headRef}
|
||||
className={cn('overflow-hidden', className)}
|
||||
style={{
|
||||
maxHeight: clampHeight,
|
||||
maskImage: truncated && !open ? collapsedMask : undefined,
|
||||
WebkitMaskImage: truncated && !open ? collapsedMask : undefined,
|
||||
}}
|
||||
>
|
||||
{leadingIcon}
|
||||
{text}
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
className='overflow-hidden'
|
||||
aria-hidden
|
||||
initial={reduceMotion ? false : { height: 0 }}
|
||||
animate={{ height: 'auto' }}
|
||||
exit={{ height: 0 }}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: TOAST_EASE }
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className={className}
|
||||
style={{ marginTop: -clampHeight }}
|
||||
initial={reduceMotion ? false : { opacity: 0, filter: 'blur(5px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
transition={
|
||||
reduceMotion ? { duration: 0 } : { duration: RESIZE_DURATION, ease: [0.2, 0, 0, 1] }
|
||||
}
|
||||
>
|
||||
{leadingIcon}
|
||||
{text}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastItem({ toast: t, geometry, reduceMotion, onDismiss, onMeasure }: ToastItemProps) {
|
||||
const contentRef = useRef<HTMLDivElement>(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 `<li>` 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 (
|
||||
<motion.li
|
||||
onMouseEnter={() => 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)]'
|
||||
>
|
||||
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<RevealText
|
||||
text={t.message}
|
||||
expanded={hovered}
|
||||
clampLines={2}
|
||||
lineHeightPx={20}
|
||||
leadingIcon={
|
||||
<span className='mr-1.5 inline-flex h-5 items-center align-top'>
|
||||
<Icon className={chipContentIconClass} />
|
||||
</span>
|
||||
}
|
||||
className='text-[var(--text-body)] text-sm leading-5'
|
||||
reduceMotion={reduceMotion}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex h-5 flex-shrink-0 items-center'>
|
||||
<Button
|
||||
variant='quiet'
|
||||
onClick={dismiss}
|
||||
aria-label='Dismiss notification'
|
||||
title='Dismiss'
|
||||
className='size-[18px] rounded-sm p-0'
|
||||
>
|
||||
<X className='size-[16px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{t.description ? (
|
||||
<RevealText
|
||||
text={t.description}
|
||||
expanded={hovered}
|
||||
clampLines={3}
|
||||
lineHeightPx={18}
|
||||
className='text-[var(--text-muted)] text-small leading-[18px]'
|
||||
reduceMotion={reduceMotion}
|
||||
/>
|
||||
) : null}
|
||||
{t.action ? (
|
||||
<Chip
|
||||
fullWidth
|
||||
flush
|
||||
onClick={() => {
|
||||
t.action!.onClick()
|
||||
dismiss()
|
||||
}}
|
||||
className={cn('justify-center', chipFilledFillTokens)}
|
||||
>
|
||||
{t.action.label}
|
||||
</Chip>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* <ToastProvider />
|
||||
* ```
|
||||
*/
|
||||
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<ToastData[]>([])
|
||||
const [heights, setHeights] = useState<Record<string, number>>({})
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
|
||||
|
||||
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<ToastFn>(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<ToastContextValue>(
|
||||
() => ({ 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 (
|
||||
<ToastContext.Provider value={ctx}>
|
||||
{children}
|
||||
{mounted
|
||||
? createPortal(
|
||||
<AnimatePresence>
|
||||
{toasts.length > 0 ? (
|
||||
<motion.ol
|
||||
key='toast-stack'
|
||||
aria-live='polite'
|
||||
aria-label='Notifications'
|
||||
className='fixed z-[var(--z-toast)] m-0 list-none p-0'
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: reduceMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeIn' },
|
||||
}}
|
||||
style={{
|
||||
right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px',
|
||||
bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px',
|
||||
width: TOAST_WIDTH,
|
||||
height: containerHeight,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseEnter={() => 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'
|
||||
>
|
||||
<AnimatePresence>
|
||||
{layout.map(({ toast, geometry }) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
geometry={geometry}
|
||||
reduceMotion={reduceMotion}
|
||||
onDismiss={dismissToast}
|
||||
onMeasure={measureToast}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.ol>
|
||||
) : null}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)
|
||||
: null}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user