Task Done Toast New
Toasts that announce finished agent runs, with a progress state that turns into success in place, plus open and undo.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-done-toast.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/io/DoneToast.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowUpRight, Check, LoaderCircle, RotateCcw, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { prefersReducedMotion } from './shared';
export type AgentToast = { id: number; title: string; detail: string; duration: string; status: 'running' | 'done' | 'undone'; progress: number };
const QUEUE = [
{ title: 'Refactored billing module', detail: '6 files changed · tests pass', duration: '2m 14s' },
{ title: 'Weekly report drafted', detail: '4 pages · 12 sources cited', duration: '48s' },
{ title: 'Inbox triaged', detail: '31 emails labelled · 4 need you', duration: '1m 02s' },
];
export type DoneToastStackProps = { className?: string };
/** Toasts that announce finished agent runs. A progress toast turns into a success state in place. */
export function DoneToastStack({ className }: DoneToastStackProps) {
const [toasts, setToasts] = useState<AgentToast[]>([]);
const n = useRef(0);
const timers = useRef<number[]>([]);
const push = (k: number) => {
const q = QUEUE[k % QUEUE.length];
const id = n.current++;
const reduce = prefersReducedMotion();
setToasts((t) => [{ id, ...q, status: (reduce ? 'done' : 'running') as AgentToast['status'], progress: reduce ? 1 : 0 }, ...t].slice(0, 3));
};
useEffect(() => {
setToasts([]);
push(2);
timers.current.push(window.setTimeout(() => push(1), 500));
timers.current.push(window.setTimeout(() => push(0), 1100));
const t = timers.current;
return () => t.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!toasts.some((t) => t.status === 'running')) return;
const id = window.setInterval(
() => setToasts((list) => list.map((t) => (t.status !== 'running' ? t : t.progress >= 1 ? { ...t, status: 'done' } : { ...t, progress: Math.min(1, t.progress + 0.035) }))),
60
);
return () => clearInterval(id);
}, [toasts]);
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
const undo = (id: number) => setToasts((t) => t.map((x) => (x.id === id ? { ...x, status: 'undone' } : x)));
return (
<div className={cn('relative flex w-full max-w-md flex-col items-stretch gap-4', className)}>
<ol className="flex flex-col gap-2" aria-live="polite">
{toasts.map((t, i) => (
<li
key={t.id}
className={cn('relative animate-fade-down overflow-hidden rounded-2xl bg-ink-850 p-3.5 shadow-pop transition-all duration-300', i > 0 && 'opacity-80')}
style={{ transform: `scale(${1 - i * 0.02})` }}
>
<div className="flex items-start gap-3">
<span
className={cn(
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full',
t.status === 'running' ? 'bg-brand-500/15 text-brand-300' : t.status === 'done' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-white/5 text-ink-400'
)}
>
{t.status === 'running' ? <LoaderCircle className="h-4 w-4 animate-spin" /> : t.status === 'done' ? <Check className="h-4 w-4 animate-scale-in" /> : <RotateCcw className="h-4 w-4" />}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-100">{t.status === 'running' ? `${t.title.split(' ')[0]}…` : t.status === 'undone' ? `Undone: ${t.title}` : t.title}</p>
<p className="mt-0.5 truncate text-xs text-ink-400">{t.status === 'running' ? `Working · ${Math.round(t.progress * 100)}%` : `${t.detail} · ${t.duration}`}</p>
{t.status === 'done' && (
<div className="mt-2.5 flex gap-1.5">
<button className="focus-ring flex h-7 items-center gap-1 rounded-md bg-white/5 px-2.5 text-xs text-ink-100 hover:bg-white/10">
Open <ArrowUpRight className="h-3 w-3" />
</button>
<button onClick={() => undo(t.id)} className="focus-ring h-7 rounded-md px-2.5 text-xs text-ink-400 hover:bg-white/5 hover:text-ink-100">
Undo
</button>
</div>
)}
</div>
<button onClick={() => dismiss(t.id)} aria-label="Dismiss" className="focus-ring -mr-1 -mt-1 flex h-7 w-7 items-center justify-center rounded-md text-ink-500 hover:bg-white/5 hover:text-ink-200">
<X className="h-3.5 w-3.5" />
</button>
</div>
{t.status === 'running' && <span className="absolute inset-x-0 bottom-0 h-0.5 bg-brand-400 transition-[width]" style={{ width: `${t.progress * 100}%` }} />}
</li>
))}
</ol>
<button onClick={() => push(n.current)} className="focus-ring self-center rounded-full border border-white/10 px-4 py-2 text-xs text-ink-300 transition-colors hover:bg-white/5 hover:text-ink-100">
Run another task
</button>
</div>
);
}
export function DoneToastDemo() {
return <DoneToastStack />;
}components/niceui/ai/io/shared.ts
import { useEffect, useRef, useState } from 'react';
export const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds elapsed while `active`, ticking at `fps`. */
export function useTicker(active: boolean, fps = 20) {
const [t, setT] = useState(0);
const base = useRef(0);
useEffect(() => {
if (!active) return;
const start = performance.now() - base.current * 1000;
const id = window.setInterval(() => {
base.current = (performance.now() - start) / 1000;
setT(base.current);
}, 1000 / fps);
return () => clearInterval(id);
}, [active, fps]);
const reset = () => {
base.current = 0;
setT(0);
};
return [t, reset] as const;
}
/** Deterministic pseudo-random 0..1. */
export const rnd = (n: number) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;