nexus/ui/src/components/ToastViewport.tsx
scotttong 2d8003d2f5 experiment: 3-panel CEO chat, artifacts, front door, and UX overhaul
New core product layout: resizable chat + artifacts panel replaces the
old wizard-only flow. Front door (create/grow), onboarding exits to chat,
CEO discusses strategy before planning. Approval actions live in the
artifacts pane, not inline in chat. Chat history drawer, animated
paperclip thinking indicator, optimistic typing, faster polling.

Rename Issue → Task across all frontend UI labels (16 files).
Add global pause/resume all agents on dashboard with sidebar badge.
Move toasts to bottom-right. Add Artifacts page and sidebar nav item.
Reorder wizard: Mission → CEO config → Launch (exits to chat).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:45:21 -07:00

99 lines
3.2 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "@/lib/router";
import { X } from "lucide-react";
import { useToast, type ToastItem, type ToastTone } from "../context/ToastContext";
import { cn } from "../lib/utils";
const toneClasses: Record<ToastTone, string> = {
info: "border-sky-300 bg-sky-50 text-sky-900 dark:border-sky-500/25 dark:bg-sky-950/60 dark:text-sky-100",
success: "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-500/25 dark:bg-emerald-950/60 dark:text-emerald-100",
warn: "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-500/25 dark:bg-amber-950/60 dark:text-amber-100",
error: "border-red-300 bg-red-50 text-red-900 dark:border-red-500/30 dark:bg-red-950/60 dark:text-red-100",
};
const toneDotClasses: Record<ToastTone, string> = {
info: "bg-sky-500 dark:bg-sky-400",
success: "bg-emerald-500 dark:bg-emerald-400",
warn: "bg-amber-500 dark:bg-amber-400",
error: "bg-red-500 dark:bg-red-400",
};
function AnimatedToast({
toast,
onDismiss,
}: {
toast: ToastItem;
onDismiss: (id: string) => void;
}) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const frame = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(frame);
}, []);
return (
<li
className={cn(
"pointer-events-auto rounded-sm border shadow-lg backdrop-blur-xl transition-[transform,opacity] duration-200 ease-out",
visible
? "translate-y-0 opacity-100"
: "translate-y-3 opacity-0",
toneClasses[toast.tone],
)}
>
<div className="flex items-start gap-3 px-3 py-2.5">
<span className={cn("mt-1 h-2 w-2 shrink-0 rounded-full", toneDotClasses[toast.tone])} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold leading-5">{toast.title}</p>
{toast.body && (
<p className="mt-1 text-xs leading-4 opacity-70">
{toast.body}
</p>
)}
{toast.action && (
<Link
to={toast.action.href}
onClick={() => onDismiss(toast.id)}
className="mt-2 inline-flex text-xs font-medium underline underline-offset-4 hover:opacity-90"
>
{toast.action.label}
</Link>
)}
</div>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => onDismiss(toast.id)}
className="mt-0.5 shrink-0 rounded p-1 opacity-50 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</li>
);
}
export function ToastViewport() {
const { toasts, dismissToast } = useToast();
if (toasts.length === 0) return null;
return (
<aside
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed bottom-3 right-3 z-[120] w-full max-w-sm px-1"
>
<ol className="flex w-full flex-col-reverse gap-2">
{toasts.map((toast) => (
<AnimatedToast
key={toast.id}
toast={toast}
onDismiss={dismissToast}
/>
))}
</ol>
</aside>
);
}