feat(emcn/toast): toast redesign — intent variants, stacking, hover reveal, dismiss-all#4909
feat(emcn/toast): toast redesign — intent variants, stacking, hover reveal, dismiss-all#4909andresdjasso wants to merge 1 commit into
Conversation
…k, 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
PR SummaryMedium Risk Overview Interaction and lifecycle changes: truncated title/description use hover reveal ( The playground wraps content in Reviewed by Cursor Bugbot for commit d47c3ae. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d47c3ae. Configure here.
| reduceMotion={reduceMotion} | ||
| resetKey={toasts[toasts.length - 1]?.id ?? ''} | ||
| onDismiss={dismissAllToasts} | ||
| /> |
There was a problem hiding this comment.
Stack timer clears actionable toasts
Medium Severity
When two or more toasts are visible, StackDismiss always auto-calls dismissAllToasts after STACK_DISMISS_MS, even if a toast was created with an action and duration: 0 to stay until dismissed. Block error “Fix in Copilot” toasts can vanish while the user still has other notifications in the stack.
Reviewed by Cursor Bugbot for commit d47c3ae. Configure here.
| const dedupKey = `${displayName}: ${errorMessage}` | ||
| const lastShownAt = recentErrorNotifications.get(dedupKey) | ||
| if (lastShownAt !== undefined && now - lastShownAt < NOTIFY_DEDUP_WINDOW_MS) return | ||
| recentErrorNotifications.set(dedupKey, now) |
There was a problem hiding this comment.
Dedup key skips error normalization
Medium Severity
notifyBlockError builds its dedup key from String(error), while addConsole passes createdEntry.error after normalizeConsoleError. The same failure from addConsole and updateConsole can produce different keys or descriptions, so duplicate error toasts can still appear within the dedup window.
Reviewed by Cursor Bugbot for commit d47c3ae. Configure here.
Greptile SummaryThis PR replaces the previous toast implementation with a fully custom component: intent variants (default/info/success/warning/error) with inline outline icons, Sonner-style collapsed stacking that fans open on hover, a "hover-reveal" for truncated text, a dismiss-all chip with an animated linear countdown ring, and persistent actionable toasts. The store layer gains a 1 500 ms dedup window so the add/update double-fire from block errors produces only one notification.
Confidence Score: 4/5Safe to merge; the core toast logic is sound and the store dedup is a clear improvement over the previous double-fire behavior. The implementation is well-thought-out: timers, route-scoped clearing, reduced-motion support, and the singleton bridge all behave correctly. The evicted-toast heights leak is a bounded memory accumulation (one stale number per evicted card, never accessed for layout), not a functional defect. The resetKey restart-on-dismiss is a UX ambiguity rather than wrong behavior. No auth, data, or crash risks are introduced. toast.tsx deserves a second look — specifically the heights cleanup on STACK_LIMIT eviction and the StackDismiss resetKey semantics. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["toast.error / toast.success / etc."] --> B{globalToast set?}
B -- No --> C[throw Error]
B -- Yes --> D["ToastProvider.addToast"]
D --> E["setToasts: append + slice -STACK_LIMIT"]
E --> F{"toasts.length >= threshold?"}
F -- Yes --> G["Clear per-toast timers — StackDismiss takes over"]
F -- No --> H["Set per-toast setTimeout for each toast"]
G --> I["StackDismiss ring counts down 6s"]
I -->|"complete or click"| J["dismissAllToasts: clear all"]
H -->|"timer fires"| K["dismissToast id"]
K --> L["Clear timer + filter toasts + cleanup heights"]
E --> N["Portal renders motion.ol with layout geometry"]
N --> O{"stack hovered?"}
O -- Yes --> P["Fan open: full heights + cumulative y offsets"]
O -- No --> Q["Collapsed pile: depth scale + offset"]
P & Q --> R["ToastItem renders with geometry"]
R --> S["useLayoutEffect measures height via ResizeObserver"]
S -->|"onMeasure"| N
R --> T["hover: RevealText tail blurs in"]
Reviews (1): Last reviewed commit: "feat(emcn/toast): redesign toast — inten..." | Re-trigger Greptile |
| @@ -293,9 +593,15 @@ export function ToastProvider({ children }: { children?: ReactNode }) { | |||
| description: input.description, | |||
| variant: input.variant ?? 'default', | |||
| action: input.action, | |||
| duration: input.duration ?? AUTO_DISMISS_MS, | |||
| /** | |||
| * Actionable toasts persist until dismissed (`duration: 0`) so the | |||
| * action — e.g. "Fix in Copilot" — can't disappear before the user | |||
| * reacts. An explicit `duration` always wins. Everything else auto- | |||
| * dismisses after the default window. | |||
| */ | |||
| duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS), | |||
| } | |||
| setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE)) | |||
| setToasts((prev) => [...prev, data].slice(-STACK_LIMIT)) | |||
| return id | |||
| }, []) | |||
There was a problem hiding this comment.
Evicted toasts leak their
heights entry. When addToast slices the oldest toast off via .slice(-STACK_LIMIT), dismissToast is never called for it, so its measured height stays in the heights map indefinitely. Over a session with many rapid arrivals the map accumulates stale entries. The fix is to clean up evicted IDs inside addToast's state setter.
| const addToast = useCallback((input: ToastInput): string => { | |
| const id = generateId() | |
| const data: ToastData = { | |
| id, | |
| message: input.message, | |
| description: input.description, | |
| variant: input.variant ?? 'default', | |
| action: input.action, | |
| /** | |
| * Actionable toasts persist until dismissed (`duration: 0`) so the | |
| * action — e.g. "Fix in Copilot" — can't disappear before the user | |
| * reacts. An explicit `duration` always wins. Everything else auto- | |
| * dismisses after the default window. | |
| */ | |
| duration: input.duration ?? (input.action ? 0 : AUTO_DISMISS_MS), | |
| } | |
| setToasts((prev) => { | |
| const next = [...prev, data].slice(-STACK_LIMIT) | |
| if (prev.length >= STACK_LIMIT) { | |
| const evictedIds = new Set(next.map((t) => t.id)) | |
| const dropped = prev.filter((t) => !evictedIds.has(t.id)) | |
| if (dropped.length > 0) { | |
| setHeights((h) => { | |
| const cleaned = { ...h } | |
| for (const t of dropped) delete cleaned[t.id] | |
| return cleaned | |
| }) | |
| } | |
| } | |
| return next | |
| }) | |
| return id | |
| }, []) |
| paused={expanded} | ||
| reduceMotion={reduceMotion} | ||
| resetKey={toasts[toasts.length - 1]?.id ?? ''} | ||
| onDismiss={dismissAllToasts} | ||
| /> |
There was a problem hiding this comment.
resetKey restarts countdown on last-toast dismiss, not only on new arrivals. resetKey is set to toasts[toasts.length - 1]?.id, so it changes whenever the tail of the array changes — including when the currently-last toast is dismissed. For example: with three toasts [A, B, C], dismissing C leaves [A, B] and changes resetKey to B.id, restarting the 6-second ring even though no new toast arrived. If that's intentional (giving users a fresh window when actively managing the stack) a brief code comment would prevent future confusion.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


Re-cut cleanly off latest
staging(the original #4907 was accidentally branched offimprovement/platform, which is now merged into staging — hence its conflicts). This is the same toast work as commit b92ea33, applied conflict-free on staging.What changed
notifyBlockErrorsplits block name (title) / error (subtext)./playground→ Toast section).🤖 Generated with Claude Code