feat(emcn/toast): toast redesign — intent variants, stacking, hover reveal, dismiss-all (#4909)

* feat(emcn/toast): redesign toast — intent variants, Sonner-style stack, hover text reveal, dismiss-all

Component (apps/sim/components/emcn/components/toast/toast.tsx):
- Variants default/info/success/warning/error, each with a distinct outline
  icon (CircleAlert/TriangleAlert/CircleCheck/Info/Bell) rendered inline with
  the message in a neutral color — no badge; intent reads from icon + copy.
- Stacking modeled on Sonner/Base-UI: a collapsed pile that fans open upward
  only when the cards are hovered. One fixed-duration expo-out tween drives all
  cards so rapid arrivals move in unison (no lagging card); cards arrive
  collapsed (expand is scoped to a wrapper around the cards, not the dismiss
  control).
- Title vs subtext hierarchy: message is a medium, primary-color title; the
  optional description is lighter/smaller subtext.
- Truncated text reveals its hidden lines on hover (RevealText): only the
  previously hidden lines blur in; the card height tracks the content so the
  action button stays pinned (no clipping). Larger bottom gradient fade hints
  at more text.
- Concentric corner radius (16px = chip 8px + 8px padding); single-line cards
  use a tighter 12px so they don't read as pills.
- Dismiss-all control: a small circular chip just outside the stack's
  bottom-left, shown at 2+ toasts. Linear auto-dismiss ring that restarts on
  each new arrival, pauses on hover, click to clear all; spring 'pop' entrance.
- Bug fixes: route-scoped clearing (toasts no longer trail across navigation),
  dedup of the add/update double-fire, actionable toasts persist by default.

Source/usage:
- stores/terminal/console/store.ts: notifyBlockError now passes the block name
  as the title and the error as the description (title/subtext), plus the dedup
  window.
- app/playground/page.tsx: Toast section added to the EMCN gallery.
- New EMCN icons: circle-alert, circle-check, info, triangle-alert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(emcn): rename Info icon to CircleInfo to resolve barrel collision

The toast redesign added an `Info` icon, but the emcn barrel already
re-exports an `Info` component (`export * from './components'` +
`export * from './icons'`), so the top-level `@/components/emcn` had a
duplicate `Info` export (TS2308). Rename the icon to CircleInfo — matching
its circle-shaped siblings (CircleAlert, CircleCheck) and resolving the
collision. The icon is consumed only by toast.tsx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* 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>

* feat(emcn/toast): tint variant icons; polish clear-all, countdown reset, teardown

- Req 6: per-variant icon tint (error/warning/success/info) from the shared
  intent palette, so error vs info is distinguishable pre-attentively in a
  mixed stack instead of only by reading the copy. Default stays neutral.
- H3: wrap the stack in AnimatePresence so clear-all / route-change fades the
  frozen stack out instead of cutting abruptly; per-card exits still play for
  single dismissals. No pointer-events change (no hover regression).
- Countdown reset now keys on a monotonic arrival counter, so dismissing the
  front card no longer restarts the whole stack's auto-dismiss timer.
- Provider teardown only nulls the global toast bindings if they're still its
  own, guarding against an out-of-order unmount with a second provider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep modal errors inside the modal instead of a screen-corner toast

Per the toast spec (modal errors stay in the modal; only page errors go to the
corner), route two in-modal failures inline:
- add-people-modal: failures now render via ChipModalError in the modal body
  (the modal stays open with the failed emails), not toast.error.
- slack setup wizard: a clipboard-copy failure shows inline beneath the copy
  button (mirroring the existing 'copied' state), not a toast.

Left as-is (verified correct, not violations): credential-detail save error
(a routed page, not a modal — page toast is correct) and import-csv success
(fires as the dialog closes — inline isn't possible).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(data-drains): surface CreateDrainModal submit error inline

Completes the modal-error migration: the create-drain form's submit failure
now renders via ChipModalError in the modal body (the modal stays open) instead
of a toast.error that escaped to the screen corner. The success-on-close toast
and the page-level drain-row action toasts are unchanged (correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(emcn/toast): trim comments to concise TSDoc per project convention

Remove inline // and JSX {/* */} comments (the project documents with TSDoc
only) and condense multi-paragraph TSDoc to 1-3 lines across toast.tsx, plus
the stray inline comments in the console store and slack wizard. Behavior and
code are unchanged — verified the non-comment source is byte-identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emcn/toast): inset on the /w index route; stronger, unified intent tints

- Positioning: the workflow-list index route /workspace/[id]/w renders the
  panel + terminal but failed includes('/w/'), so a toast there rendered under
  the panel. Match /\/w(\/|$)/ so both the editor and the index inset.
- Tint: unify all four intent icons onto the badge palette and use
  --badge-success-text (a darker green) so success isn't ~1.9:1 / washed out
  on the light card; error/success now match warning/info's token family.
- Gate the card enter animation on reduceMotion for consistency with the rest
  of the file (behaviour was already correct via duration 0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(upgrade): surface billing errors via toast instead of native alert()

The upgrade flow used native alert() for upgrade / switch-plan / switch-interval
failures. Route them through the unified toast (toast.error) like the rest of
the app — it's inside the ToastProvider tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(emcn): drop CountdownRing barrel export + dead toast keyframes

Completes removing what the framer-motion toast redesign orphaned: the
CountdownRing re-export and the zero-reference notification-/toast-* keyframes.

* fix(console): normalize error in notifyBlockError so dedup keys match

addConsole passes a normalizeConsoleError'd error while updateConsole passed the
raw update.error, so the dedup key (String(error)) differed between the two
paths and the duplicate block-error toast could still slip through. Normalize
inside notifyBlockError so both paths produce the same key and description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emcn/toast): expire timed toasts stacked beside a persistent one

Suppressing the stack auto-countdown when a persistent toast is present (so it
can't clear the action) also stopped timed toasts in that stack from expiring.
Fall back to per-toast timers whenever the stack countdown isn't auto-firing,
so timed toasts auto-dismiss individually while the persistent one stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(add-people): keep the specific add-failure reason in the inline error

The inline ChipModalError dropped the server/validation detail the toast used
to show via description. Fold getErrorMessage(firstError) back into the message
so users still see why the add failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emcn/toast): don't evict a persistent toast at the stack cap

slice(-STACK_LIMIT) dropped the oldest card on a 4th arrival even if it was a
persistent (actionable) error meant to stay until dismissed. Evict the oldest
auto-dismissable toast instead, falling back to the oldest only when every
toast is persistent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(console): scope error-dedup to the block execution

The dedup keyed on block+message for 1.5s, but the toast stack clears on
navigation — so a genuinely new same-block error within that window (e.g. a
re-run, or after navigating) was suppressed with no replacement toast. Key the
dedup on the block execution (getBlockExecutionKey) so only the same execution's
addConsole/updateConsole double-fire collapses; a different execution always
toasts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emcn/toast): let global status toasts survive navigation (persistAcrossRoutes)

The route-clear dismissed every toast on navigation, including the persistent
realtime connection/reconnect status toast — which then never re-showed (the
provider's id ref short-circuited). Add a persistAcrossRoutes flag: the
navigation clear now keeps flagged toasts and only drops route-scoped ones, and
the WorkspacePermissionsProvider status toasts set it. Page-scoped toasts
(including actionable block errors) still clear on navigation as before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(emcn/toast): reset expanded when the stack empties

The hover wrapper unmounts without firing mouse-leave when the last toast goes
(dismiss / clear-all / navigation), so expanded could stay true and stop the
next toasts from auto-dismissing. Force expanded false whenever the stack is
empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: andresdjasso <andresdjasso@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
andres
2026-06-08 20:59:19 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 andresdjasso waleed
parent a72e35e2f4
commit 05408fd1ce
17 changed files with 1617 additions and 1083 deletions
-53
View File
@@ -844,59 +844,6 @@ input[type="search"]::-ms-clear {
}
}
@keyframes notification-enter {
from {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
}
@keyframes notification-countdown {
from {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 34.56;
}
}
@keyframes notification-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) scale(1);
}
to {
opacity: 0;
transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
}
}
@keyframes toast-enter {
from {
opacity: 0;
transform: translateX(var(--stack-offset, 0px)) translateY(8px) scale(0.97);
}
to {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) translateY(0) scale(1);
}
}
@keyframes toast-exit {
from {
opacity: 1;
transform: translateX(var(--stack-offset, 0px)) translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateX(var(--stack-offset, 0px)) translateY(8px) scale(0.97);
}
}
/* WandPromptBar status indicator */
@keyframes smoke-pulse {
0%,
File diff suppressed because it is too large Load Diff
@@ -6,10 +6,10 @@ import { getErrorMessage } from '@sim/utils/errors'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
toast,
} from '@/components/emcn'
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -42,6 +42,7 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo
const [emailsToAdd, setEmailsToAdd] = useState<string[]>([])
const [roleToAdd, setRoleToAdd] = useState<WorkspaceCredentialRole>('member')
const [isAdding, setIsAdding] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const workspaceUserIdByEmail = useMemo(
() =>
@@ -73,11 +74,13 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo
const handleClose = useCallback(() => {
setEmailsToAdd([])
setRoleToAdd('member')
setSubmitError(null)
onOpenChange(false)
}, [onOpenChange])
const handleAddPeople = useCallback(async () => {
if (emailsToAdd.length === 0 || isAdding) return
setSubmitError(null)
const targets = emailsToAdd
.map((email) => {
const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
@@ -103,11 +106,11 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo
(result): result is PromiseRejectedResult => result.status === 'rejected'
)
logger.error('Failed to add some credential members', firstError?.reason)
toast.error(
const reason = getErrorMessage(firstError?.reason, 'Please try again in a moment.')
setSubmitError(
failures.length === targets.length
? "Couldn't add people"
: `Couldn't add ${failures.length} of ${targets.length} people`,
{ description: getErrorMessage(firstError?.reason, 'Please try again in a moment.') }
? `Couldn't add people. ${reason}`
: `Couldn't add ${failures.length} of ${targets.length} people. ${reason}`
)
} finally {
setIsAdding(false)
@@ -152,6 +155,7 @@ export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleMo
onChange={(role) => setRoleToAdd(role as WorkspaceCredentialRole)}
disabled={isAdding}
/>
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
@@ -96,7 +96,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
clearRealtimeStatusNotification()
const id = toast.error(realtimeStatusMessage, { duration: 0 })
const id = toast.error(realtimeStatusMessage, { duration: 0, persistAcrossRoutes: true })
realtimeStatusNotificationIdRef.current = id
realtimeStatusNotificationMessageRef.current = realtimeStatusMessage
@@ -130,6 +130,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
try {
toast.error('Connection unavailable', {
duration: 0,
persistAcrossRoutes: true,
action: { label: 'Refresh', onClick: () => window.location.reload() },
})
setHasShownOfflineNotification(true)
@@ -1,6 +1,7 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { toast } from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { billingSwitchPlanContract } from '@/lib/api/contracts/subscription'
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
@@ -129,7 +130,7 @@ export function useUpgradeState(): UpgradeState {
...(seats ? { seats } : {}),
})
} catch (error) {
alert(getErrorMessage(error, 'Unknown error occurred'))
toast.error(getErrorMessage(error, 'Unknown error occurred'))
}
},
[handleUpgrade, isAnnual]
@@ -180,7 +181,7 @@ export function useUpgradeState(): UpgradeState {
})
await refetchSubscription()
} catch (e) {
alert(getErrorMessage(e, 'Failed to upgrade'))
toast.error(getErrorMessage(e, 'Failed to upgrade'))
}
}, [subscription.isTeam, isAnnual, refetchSubscription])
@@ -196,7 +197,7 @@ export function useUpgradeState(): UpgradeState {
})
await refetchSubscription()
} catch (e) {
alert(getErrorMessage(e, 'Failed to switch plan'))
toast.error(getErrorMessage(e, 'Failed to switch plan'))
}
}, [subscription.plan, subscription.isTeam, refetchSubscription])
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import { getErrorMessage } from '@sim/utils/errors'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Chip } from '@/components/emcn'
import { ArrowLeft, Chip, toast } from '@/components/emcn'
import {
getUpgradeCardCta,
type PlanCardCta,
@@ -91,7 +91,7 @@ export function Upgrade({ workspaceId }: UpgradeProps) {
onClick: () =>
state
.handleSwitchInterval(state.isAnnual ? 'year' : 'month')
.catch((e) => alert(getErrorMessage(e, 'Failed to switch interval'))),
.catch((e) => toast.error(getErrorMessage(e, 'Failed to switch interval'))),
}
}
return { ...cta, onClick: () => {}, disabled: true }
@@ -3,7 +3,7 @@
import { type ReactNode, useCallback, useMemo, useState } from 'react'
import { Check, ChevronRight, Clipboard, Info } from 'lucide-react'
import { useShallow } from 'zustand/react/shallow'
import { Checkbox, Input, Label, SecretInput, Tooltip, toast, Wizard } from '@/components/emcn'
import { Checkbox, Input, Label, SecretInput, Tooltip, Wizard } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
import { useWebhookManagement } from '@/hooks/use-webhook-management'
@@ -263,15 +263,17 @@ interface StepCreateProps {
function StepCreate({ manifestJson, canCopy }: StepCreateProps) {
const [copied, setCopied] = useState<boolean>(false)
const [copyFailed, setCopyFailed] = useState<boolean>(false)
const handleCopy = useCallback(async () => {
if (!canCopy) return
try {
await navigator.clipboard.writeText(manifestJson)
setCopyFailed(false)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
toast.error("Couldn't copy manifest — copy it manually from the developer console.")
setCopyFailed(true)
}
}, [canCopy, manifestJson])
@@ -301,6 +303,11 @@ function StepCreate({ manifestJson, canCopy }: StepCreateProps) {
<Clipboard className='size-[12px] text-[var(--text-muted)]' />
))}
</button>
{copyFailed ? (
<p className='mt-1.5 text-[var(--text-error)] text-xs'>
Couldn't copy manifest — copy it manually from the developer console.
</p>
) : null}
</SubStep>
<SubStep n={2}>
Open the{' '}
@@ -169,7 +169,6 @@ export {
} from './tag-input/tag-input'
export { Textarea } from './textarea/textarea'
export { TimePicker, timePickerVariants } from './time-picker/time-picker'
export { CountdownRing } from './toast/countdown-ring'
export { ToastProvider, toast, useToast } from './toast/toast'
export {
clamp,
@@ -1,37 +0,0 @@
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
interface CountdownRingProps {
duration: number
paused?: boolean
className?: string
}
export function CountdownRing({ duration, paused = false, className }: CountdownRingProps) {
return (
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='currentColor' strokeWidth='1.5' opacity={0.2} />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${duration}ms linear forwards`,
animationPlayState: paused ? 'paused' : 'running',
}}
/>
</svg>
)
}
@@ -2,51 +2,82 @@
import {
type ComponentType,
type CSSProperties,
createContext,
type ReactNode,
type SVGProps,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { generateId } from '@sim/utils/id'
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from 'framer-motion'
import { usePathname } from 'next/navigation'
import { createPortal } from 'react-dom'
import { Button } from '@/components/emcn/components/button/button'
import { Chip } from '@/components/emcn/components/chip/chip'
import { CountdownRing } from '@/components/emcn/components/toast/countdown-ring'
import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
import { Bell } from '@/components/emcn/icons/bell'
import { Check } from '@/components/emcn/icons/check'
import { CircleAlert } from '@/components/emcn/icons/circle-alert'
import { CircleCheck } from '@/components/emcn/icons/circle-check'
import { CircleInfo } from '@/components/emcn/icons/circle-info'
import { TriangleAlert } from '@/components/emcn/icons/triangle-alert'
import { X } from '@/components/emcn/icons/x'
import { cn } from '@/lib/core/utils/cn'
const AUTO_DISMISS_MS = 5000
const EXIT_ANIMATION_MS = 200
/**
* Visible stack depth. The per-step `translateX` cascade keeps the depth
* cue legible; raising this much higher would crowd the bottom-right edge
* once the portal insets for the workflow panel + terminal.
*/
const MAX_VISIBLE = 3
const STACK_OFFSET_PX = 3
/** Whole-stack auto-dismiss window once the dismiss-all control appears (2+ toasts). */
const STACK_DISMISS_MS = 6000
/** Toast count at which the dismiss-all control and stack countdown take over. */
const STACK_DISMISS_THRESHOLD = 2
type ToastVariant = 'default' | 'success' | 'error'
/** Card width; tracks the workflow-panel inset on narrow viewports. */
const TOAST_WIDTH = 'min(100vw - 2rem, 280px)'
/**
* Leading icon shown next to the toast title, per variant. Icons are EMCN
* primitives so this surface stays inside the design system. All variants
* share the neutral `text-[var(--text-icon)]` color used by sidebar items;
* variant intent is communicated through icon shape and message content,
* not tint.
*/
/** 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 signals intent, {@link VARIANT_ICON_COLOR} tints it. */
const VARIANT_ICON: Record<ToastVariant, ComponentType<SVGProps<SVGSVGElement>>> = {
default: Bell,
error: Bell,
success: Check,
info: CircleInfo,
success: CircleCheck,
warning: TriangleAlert,
error: CircleAlert,
}
/** Per-variant icon tint from the shared badge intent palette; `default` stays neutral. */
const VARIANT_ICON_COLOR: Record<ToastVariant, string> = {
default: 'text-[var(--text-icon)]',
info: 'text-[var(--badge-blue-text)]',
success: 'text-[var(--badge-success-text)]',
warning: 'text-[var(--badge-amber-text)]',
error: 'text-[var(--badge-error-text)]',
}
interface ToastAction {
@@ -61,6 +92,7 @@ interface ToastData {
variant: ToastVariant
action?: ToastAction
duration: number
persistAcrossRoutes: boolean
}
type ToastInput = {
@@ -69,15 +101,24 @@ type ToastInput = {
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
/** Dismisses a single toast by id. No-op if the id is unknown or the provider is unmounted. */
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. No-op when the provider is unmounted. */
/** Dismisses every visible toast. */
dismissAll: () => void
}
@@ -97,19 +138,21 @@ 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 function. Requires `<ToastProvider>` to be mounted.
* 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') } })
* toast({ message: 'Hello', variant: 'default' })
* ```
*/
export const toast: ToastFn = createToastFn((input) => {
@@ -119,112 +162,212 @@ export const toast: ToastFn = createToastFn((input) => {
return globalToast(input)
})
/** Hook to access the toast function and dismiss helper from context. */
/** 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 ToastItemProps {
toast: ToastData
depth: number
isPaused: boolean
onPause: () => void
onDismiss: (id: string) => void
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
}
function ToastItem({ toast: t, depth, isPaused, onPause, onDismiss }: ToastItemProps) {
const [exiting, setExiting] = useState(false)
const exitTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
interface ToastItemProps {
toast: ToastData
geometry: ToastGeometry
reduceMotion: boolean
onDismiss: (id: string) => void
onMeasure: (id: string, height: number) => void
}
const dismiss = useCallback(() => {
setExiting(true)
exitTimerRef.current = setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
}, [onDismiss, t.id])
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
}
useEffect(() => {
return () => clearTimeout(exitTimerRef.current)
}, [])
/**
* 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
const showCountdown = !isPaused && !exiting && t.duration > 0
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])
/**
* The `--stack-offset` custom property is read by the `notification-enter` /
* `notification-exit` keyframes (see `globals.css`) so each item animates in
* along its target translate-X without duplicating per-depth keyframe rules.
*/
const style = {
'--stack-offset': `${depth * STACK_OFFSET_PX}px`,
animation: exiting
? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
: 'notification-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as CSSProperties
const Icon = VARIANT_ICON[t.variant]
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
style={style}
className='w-[min(100vw-2rem,280px)] self-end overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)] shadow-[var(--shadow-overlay)]'
<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 className='flex flex-col gap-2 p-2'>
<div className='flex items-start gap-2.5'>
{/*
* Each affordance row (leading icon, close cluster) is wrapped in a
* `h-5` flex so its center sits on the title's first-line center
* (`text-[14px] leading-5`). Glyphs are all `size-[14px]` so the
* leading variant icon, title, and trailing close icon read as a
* single horizontal rhythm regardless of how many lines the title
* or description wrap to.
*/}
<div className='flex h-5 flex-shrink-0 items-center'>
<Icon className='size-[14px] text-[var(--text-icon)]' />
</div>
<div className='flex min-w-0 flex-1 flex-col'>
<span className='line-clamp-2 text-[14px] text-[var(--text-body)] leading-5'>
{t.message}
</span>
<div ref={contentRef} className='flex flex-col gap-2 p-2'>
<div className='flex items-start gap-2'>
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<RevealText
text={t.message}
expanded={hovered}
clampLines={2}
lineHeightPx={20}
leadingIcon={
<Icon
className={cn(
'mr-[5px] inline-block size-[14px] translate-y-[-2px] align-middle',
VARIANT_ICON_COLOR[t.variant]
)}
/>
}
className='font-medium text-[14px] text-[var(--text-primary)] leading-5'
reduceMotion={reduceMotion}
/>
{t.description ? (
<p className='line-clamp-3 text-[12px] text-[var(--text-muted)] leading-4'>
{t.description}
</p>
<RevealText
text={t.description}
expanded={hovered}
clampLines={3}
lineHeightPx={18}
className='text-[13px] text-[var(--text-secondary)] leading-[18px]'
reduceMotion={reduceMotion}
/>
) : null}
</div>
<div className='flex h-5 flex-shrink-0 items-center gap-1.5'>
{showCountdown ? (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='quiet'
onClick={onPause}
aria-label='Keep notifications visible'
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm p-0 text-[var(--text-icon)]'
>
<CountdownRing duration={t.duration} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='z-[calc(var(--z-toast)+1)]'>
<p>Keep visible</p>
</Tooltip.Content>
</Tooltip.Root>
) : null}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='quiet'
onClick={dismiss}
aria-label='Dismiss notification'
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm p-0'
>
<X className='size-[14px] text-[var(--text-icon)]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content className='z-[calc(var(--z-toast)+1)]'>
<p>Dismiss</p>
</Tooltip.Content>
</Tooltip.Root>
<div className='flex h-5 flex-shrink-0 items-center'>
<Button
variant='quiet'
onClick={dismiss}
aria-label='Dismiss notification'
title='Dismiss'
className='flex size-[18px] flex-shrink-0 items-center justify-center rounded-sm p-0'
>
<X className='size-[14px] text-[var(--text-icon)]' />
</Button>
</div>
</div>
{t.action ? (
@@ -242,18 +385,119 @@ function ToastItem({ toast: t, depth, isPaused, onPause, onDismiss }: ToastItemP
</Chip>
) : null}
</div>
</div>
</motion.li>
)
}
interface StackDismissProps {
/** When held (stack hovered), the countdown pauses so it can't dismiss mid-read. */
paused: boolean
/**
* Whether the ring auto-fires. `false` when the stack holds a persistent
* toast, so an actionable toast can't be cleared from under the user; the
* control still dismisses on click.
*/
autoDismiss: boolean
reduceMotion: boolean
/** Changes whenever a new toast arrives; restarts the countdown from zero. */
resetKey: string
onDismiss: () => void
}
/**
* Dismiss-all control shown once multiple toasts pile up: a ring fills over
* `STACK_DISMISS_MS` and clears the whole stack, hovering holds it, and clicking
* dismisses immediately.
*/
function StackDismiss({
paused,
autoDismiss,
reduceMotion,
resetKey,
onDismiss,
}: StackDismissProps) {
const progress = useMotionValue(0)
const onDismissRef = useRef(onDismiss)
const controlsRef = useRef<ReturnType<typeof animate> | null>(null)
const [hovered, setHovered] = useState(false)
const held = paused || hovered
const heldRef = useRef(held)
useEffect(() => {
heldRef.current = held
}, [held])
useEffect(() => {
onDismissRef.current = onDismiss
}, [onDismiss])
useEffect(() => {
progress.set(0)
if (!autoDismiss) {
controlsRef.current = null
return
}
const controls = animate(progress, 1, {
duration: STACK_DISMISS_MS / 1000,
ease: 'linear',
onComplete: () => onDismissRef.current(),
})
controlsRef.current = controls
if (heldRef.current) controls.pause()
return () => controls.stop()
}, [progress, resetKey, autoDismiss])
useEffect(() => {
const controls = controlsRef.current
if (!controls) return
if (held) controls.pause()
else controls.play()
}, [held])
return (
<motion.button
type='button'
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => onDismissRef.current()}
aria-label='Dismiss all notifications'
initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.5, transition: { duration: 0.12, ease: 'easeIn' } }
}
transition={reduceMotion ? { duration: 0 } : { type: 'spring', stiffness: 520, damping: 24 }}
className='pointer-events-auto absolute bottom-[8px] left-[-30px] z-50 flex size-[22px] items-center justify-center rounded-full bg-[var(--bg)] text-[var(--text-icon)] shadow-[var(--shadow-overlay)] transition-colors hover-hover:text-[var(--text-body)]'
>
<svg
viewBox='0 0 22 22'
className='-rotate-90 absolute inset-0 size-full'
fill='none'
aria-hidden
>
<circle cx='11' cy='11' r='10' stroke='var(--border-1)' strokeWidth='1.5' />
<motion.circle
cx='11'
cy='11'
r='10'
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
style={{ pathLength: progress }}
/>
</svg>
<X className='size-[10px]' />
</motion.button>
)
}
/**
* Toast container that renders toasts via portal. Mount once in your root
* layout. Each item is a chip-modal-styled card with a leading EMCN icon,
* header, optional description, and a trailing close/countdown cluster.
* Items stack bottom-right via grid-overlay + `--stack-offset` (consumed
* by the `notification-enter` / `notification-exit` keyframes in
* `globals.css`). Clicking any countdown ring pauses every active timer
* until the stack empties.
* 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
@@ -262,29 +506,38 @@ function ToastItem({ toast: t, depth, isPaused, onPause, onDismiss }: ToastItemP
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const pathname = usePathname()
/**
* On workflow pages the toast portal must sit inside the workflow surface
* — not over the right-side panel or the bottom terminal. Mirrors the
* `ModalContent` workflow-aware inset using the same `--panel-width` and
* `--terminal-height` CSS variables maintained by the panel + terminal
* stores.
*/
const isWorkflowPage = pathname?.includes('/w/') ?? false
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 [isPaused, setIsPaused] = useState(false)
const [heights, setHeights] = useState<Record<string, number>>({})
const [expanded, setExpanded] = useState(false)
const [mounted, setMounted] = useState(false)
/** Monotonic arrival count; changes only on a new toast, so dismissing the front card doesn't restart the stack countdown. */
const [arrivalCount, setArrivalCount] = useState(0)
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())
const isPausedRef = useRef(false)
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(() => {
isPausedRef.current = isPaused
}, [isPaused])
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 = {
@@ -293,9 +546,17 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
description: input.description,
variant: input.variant ?? 'default',
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,
duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS),
persistAcrossRoutes: input.persistAcrossRoutes ?? false,
}
setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
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
})
setArrivalCount((c) => c + 1)
return id
}, [])
@@ -306,57 +567,98 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
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([])
}, [])
const pauseAll = useCallback(() => {
setIsPaused(true)
isPausedRef.current = true
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
setHeights({})
}, [])
/**
* Per-toast auto-dismiss timers. Cleared when the stack empties so a fresh
* arrival after a pause gets new timers. Pausing clears all live timers.
* 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])
/**
* A persistent toast (`duration <= 0`) pins the stack: the dismiss-all ring's
* auto-countdown is suppressed so the action can't be cleared from under the
* user. The ring still renders and dismisses on click.
*/
const hasPersistentToast = toasts.some((t) => t.duration <= 0)
/**
* Per-toast auto-dismiss timers. The stack ring owns dismissal when it
* auto-fires (2+ toasts, none persistent); otherwise — a lone toast, or a
* stack pinned by a persistent toast — each timed toast runs its own timer so
* it still expires while the persistent one stays. Hover holds them.
*/
useEffect(() => {
if (toasts.length === 0) {
if (isPausedRef.current) setIsPaused(false)
for (const timer of timersRef.current.values()) clearTimeout(timer)
timersRef.current.clear()
const timers = timersRef.current
const stackAutoDismiss = toasts.length >= STACK_DISMISS_THRESHOLD && !hasPersistentToast
if (toasts.length === 0 || expanded || stackAutoDismiss) {
for (const timer of timers.values()) clearTimeout(timer)
timers.clear()
return
}
if (isPausedRef.current) return
const timers = timersRef.current
const activeIds = new Set<string>()
for (const t of toasts) {
if (t.duration <= 0 || timers.has(t.id)) continue
activeIds.add(t.id)
timers.set(
t.id,
setTimeout(() => {
timers.delete(t.id)
if (isPausedRef.current) return
dismissToast(t.id)
}, t.duration)
)
}
for (const [id, timer] of timers) {
if (!activeIds.has(id) && !toasts.some((t) => t.id === id)) {
if (!toasts.some((t) => t.id === id)) {
clearTimeout(timer)
timers.delete(id)
}
}
}, [toasts, dismissToast])
}, [toasts, expanded, hasPersistentToast, dismissToast])
useEffect(() => {
const timers = timersRef.current
@@ -365,21 +667,23 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}
}, [])
/**
* Stable across mounts because `addToast` is memoized with no deps. Capturing
* once at mount lets the module-level `toast` re-export bind to the live
* provider without re-allocating wrappers on every render.
*/
/** 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(() => {
globalToast = toastFn.current
const fn = toastFn.current
globalToast = fn
globalDismiss = dismissToast
globalDismissAll = dismissAllToasts
return () => {
globalToast = null
globalDismiss = null
globalDismissAll = null
if (globalToast === fn) globalToast = null
if (globalDismiss === dismissToast) globalDismiss = null
if (globalDismissAll === dismissAllToasts) globalDismissAll = null
}
}, [dismissToast, dismissAllToasts])
@@ -388,33 +692,89 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
[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 && toasts.length > 0
{mounted
? createPortal(
<Tooltip.Provider>
<div
aria-live='polite'
aria-label='Notifications'
className='fixed z-[var(--z-toast)] grid'
style={{
right: isWorkflowPage ? 'calc(var(--panel-width) + 16px)' : '16px',
bottom: isWorkflowPage ? 'calc(var(--terminal-height) + 16px)' : '16px',
}}
>
{[...toasts].reverse().map((t, index, stacked) => (
<ToastItem
key={t.id}
toast={t}
depth={stacked.length - index - 1}
isPaused={isPaused}
onPause={pauseAll}
onDismiss={dismissToast}
/>
))}
</div>
</Tooltip.Provider>,
<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,
}}
>
<AnimatePresence initial={false}>
{toasts.length >= STACK_DISMISS_THRESHOLD ? (
<StackDismiss
key='dismiss-all'
paused={expanded}
autoDismiss={!hasPersistentToast}
reduceMotion={reduceMotion}
resetKey={String(arrivalCount)}
onDismiss={dismissAllToasts}
/>
) : null}
</AnimatePresence>
<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}
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* CircleAlert icon component - circular alert used for error intent.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleAlert(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='M12 8v4' />
<path d='M12 16h.01' />
</svg>
)
}
@@ -0,0 +1,26 @@
import type { SVGProps } from 'react'
/**
* CircleCheck icon component - circular checkmark used for success intent.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleCheck(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='m9 12 2 2 4-4' />
</svg>
)
}
@@ -0,0 +1,29 @@
import type { SVGProps } from 'react'
/**
* CircleInfo icon — a circled "i", used for the info intent. Named for its
* shape to match its siblings (`CircleAlert`, `CircleCheck`) and to avoid
* colliding with the `Info` component re-exported from the emcn barrel.
* @param props - SVG properties including className, fill, etc.
*/
export function CircleInfo(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<circle cx='12' cy='12' r='10' />
<path d='M12 16v-4' />
<path d='M12 8h.01' />
</svg>
)
}
+4
View File
@@ -14,6 +14,9 @@ export { Calendar } from './calendar'
export { Card } from './card'
export { Check } from './check'
export { ChevronDown } from './chevron-down'
export { CircleAlert } from './circle-alert'
export { CircleCheck } from './circle-check'
export { CircleInfo } from './circle-info'
export { Clipboard } from './clipboard'
export { ClipboardList } from './clipboard-list'
export { Clock } from './clock'
@@ -95,6 +98,7 @@ export { ThumbsUp } from './thumbs-up'
export { Trash } from './trash'
export { TrashOutline } from './trash-outline'
export { Trash2 } from './trash2'
export { TriangleAlert } from './triangle-alert'
export { TypeBoolean } from './type-boolean'
export { TypeJson } from './type-json'
export { TypeNumber } from './type-number'
@@ -0,0 +1,27 @@
import type { SVGProps } from 'react'
/**
* TriangleAlert icon component - triangular warning used for warning intent.
* @param props - SVG properties including className, fill, etc.
*/
export function TriangleAlert(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns='http://www.w3.org/2000/svg'
width='24'
height='24'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='1.55'
strokeLinecap='round'
strokeLinejoin='round'
aria-hidden='true'
{...props}
>
<path d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z' />
<path d='M12 9v4' />
<path d='M12 17h.01' />
</svg>
)
}
@@ -13,6 +13,7 @@ import {
ChipInput,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
@@ -419,6 +420,7 @@ function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {
const [destState, setDestState] = useState<unknown>(
() => DESTINATION_FORM_REGISTRY[DESTINATION_TYPES[0]].initialState
)
const [submitError, setSubmitError] = useState<string | null>(null)
const spec = DESTINATION_FORM_REGISTRY[destinationType]
const canSubmit = name.trim().length > 0 && spec.isComplete(destState)
@@ -430,6 +432,7 @@ function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {
async function handleSubmit() {
if (!canSubmit) return
setSubmitError(null)
try {
const body = {
name: name.trim(),
@@ -443,7 +446,7 @@ function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {
} catch (error) {
const msg = toError(error).message
logger.error('Failed to create data drain', { error: msg })
toast.error(msg)
setSubmitError(msg)
}
}
@@ -488,6 +491,7 @@ function CreateDrainModal({ organizationId, onClose }: CreateDrainModalProps) {
<section className='flex flex-col gap-3 px-2'>
<spec.FormFields state={destState} setState={setDestState} />
</section>
<ChipModalError>{submitError}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={onClose}
+36 -4
View File
@@ -241,22 +241,50 @@ function appendWorkflowEntry(
interface NotifyBlockErrorParams {
error: unknown
blockName: string
blockId: string
executionId?: string
logContext: Record<string, unknown>
}
const notifyBlockError = ({ error, blockName, logContext }: NotifyBlockErrorParams) => {
/**
* A single block failure surfaces through both `addConsole` (initial entry)
* and `updateConsole` (streaming/finalize), so the same logical error asks to
* toast twice within the same tick. Collapse them inside a short window. The
* key is scoped to the block execution (not just block + message), so a re-run
* or a different execution still toasts — even within the window, and even
* after the stack was cleared on navigation.
*/
const NOTIFY_DEDUP_WINDOW_MS = 1500
const recentErrorNotifications = new Map<string, number>()
const notifyBlockError = ({
error,
blockName,
blockId,
executionId,
logContext,
}: NotifyBlockErrorParams) => {
const settings = getQueryClient().getQueryData<GeneralSettings>(generalSettingsKeys.settings())
const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true
if (!isErrorNotificationsEnabled) return
try {
const errorMessage = String(error)
const errorMessage = normalizeConsoleError(error) ?? String(error)
const displayName = blockName || 'Unknown Block'
const displayMessage = `${displayName}: ${errorMessage}`
const copilotMessage = `${errorMessage}\n\nError in ${displayName}.\n\nPlease fix this.`
toast.error(displayMessage, {
const now = Date.now()
for (const [key, shownAt] of recentErrorNotifications) {
if (now - shownAt >= NOTIFY_DEDUP_WINDOW_MS) recentErrorNotifications.delete(key)
}
const dedupKey = `${getBlockExecutionKey(blockId, executionId)}: ${errorMessage}`
const lastShownAt = recentErrorNotifications.get(dedupKey)
if (lastShownAt !== undefined && now - lastShownAt < NOTIFY_DEDUP_WINDOW_MS) return
recentErrorNotifications.set(dedupKey, now)
toast.error(displayName, {
description: errorMessage,
action: {
label: 'Fix in Copilot',
onClick: () => sendMothershipMessage(copilotMessage),
@@ -322,6 +350,8 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
notifyBlockError({
error: createdEntry.error,
blockName: createdEntry.blockName || 'Unknown Block',
blockId: createdEntry.blockId,
executionId: createdEntry.executionId,
logContext: { entryId: createdEntry.id },
})
}
@@ -610,6 +640,8 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
notifyBlockError({
error: update.error,
blockName: update.blockName || matchingEntry?.blockName || 'Unknown Block',
blockId,
executionId,
logContext: { blockId },
})
}