import { useStore } from '@nanostores/react' import { type ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { CopyButton } from '@/components/ui/copy-button' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AlertCircle, AlertTriangle, CheckCircle2, type IconComponent, Info } from '@/lib/icons' import { cn } from '@/lib/utils' import { $notifications, type AppNotification, clearNotifications, dismissNotification, type NotificationKind } from '@/store/notifications' type ToneVariant = 'default' | 'destructive' | 'warning' | 'success' const tone: Record = { error: { icon: AlertCircle, iconClass: 'text-destructive', variant: 'destructive' }, warning: { icon: AlertTriangle, iconClass: 'text-primary', variant: 'warning' }, info: { icon: Info, iconClass: 'text-muted-foreground', variant: 'default' }, success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' } } const STACK_SURFACE = 'pointer-events-auto border border-(--stroke-nous) bg-popover/95 shadow-nous backdrop-blur-md' export function NotificationStack() { const notifications = useStore($notifications) const { t } = useI18n() const lastNotificationIdRef = useRef(null) const [expanded, setExpanded] = useState(false) const copy = t.notifications useEffect(() => { if (notifications.length <= 1) { setExpanded(false) } }, [notifications.length]) useEffect(() => { const latest = notifications[0] if (!latest || latest.id === lastNotificationIdRef.current) { return } lastNotificationIdRef.current = latest.id if (latest.kind === 'success') { triggerHaptic('success') } else if (latest.kind === 'error') { triggerHaptic('error') } else if (latest.kind === 'warning') { triggerHaptic('warning') } }, [notifications]) if (notifications.length === 0) { return null } const [latest, ...olderNotifications] = notifications const overflowCount = olderNotifications.length // Portaled to with a z above the Radix dialog layer (overlay z-[120], // content z-[130]). Without the portal the stack lives inside the React root // subtree, which any body-level dialog/overlay portal paints over — so a // success toast fired while a dialog is open (or over an OverlayView page) // was invisible. The titlebar-height var only exists inside the app shell // scope, so fall back to its constant (34px) when mounted on . return createPortal(
{expanded && olderNotifications.map(n => )} {overflowCount > 0 && (
)}
, document.body ) } function NotificationItem({ notification }: { notification: AppNotification }) { const styles = tone[notification.kind] const Icon = styles.icon const hasDetail = Boolean(notification.detail && notification.detail !== notification.message) const { t } = useI18n() const copy = t.notifications return (
{notification.title && {notification.title}}

{notification.message}

{hasDetail && } {notification.action && ( )}
) } function NotificationDetail({ detail }: { detail: string }) { const { t } = useI18n() const copy = t.notifications return (
{copy.details}
          {detail}
        
{copy.copyDetail}
) } export function InlineNotice({ kind = 'info', title, children, className }: { kind?: NotificationKind title?: string children: ReactNode className?: string }) { const styles = tone[kind] const Icon = styles.icon return ( {title && {title}} {children} ) }