mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
co-authored by
Claude Opus 4.8
andresdjasso
waleed
parent
a72e35e2f4
commit
05408fd1ce
@@ -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%,
|
||||
|
||||
+888
-785
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -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 }
|
||||
|
||||
+9
-2
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user