Connection Status New
An offline → reconnecting → back-online bar above the composer, with messages that queue and send on reconnect.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-connection-status.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/errors/ConnectionStatus.tsx
import { useEffect, useRef, useState } from 'react';
import { Check, Clock, RefreshCw, Wifi, WifiOff } from 'lucide-react';
import { cn } from '@/lib/cn';
import { AssistantMsg, Thread, UserMsg, useCountdown, useTimers } from './shared';
export type ConnectionState = 'online' | 'offline' | 'reconnecting';
export type ConnectionStatusProps = {
state: ConnectionState;
attempt?: number;
/** Timestamp (ms) of the next reconnect attempt. */
nextAttemptAt?: number | null;
queued?: number;
onRetryNow?: () => void;
className?: string;
};
/** A slim bar that sits above the composer. It hides itself shortly after coming back online. */
export function ConnectionStatus({ state, attempt = 1, nextAttemptAt = null, queued = 0, onRetryNow, className }: ConnectionStatusProps) {
const secs = useCountdown(state === 'offline' ? nextAttemptAt : null);
const [showOnline, setShowOnline] = useState(false);
const prev = useRef(state);
useEffect(() => {
if (state === 'online' && prev.current !== 'online') {
setShowOnline(true);
const id = window.setTimeout(() => setShowOnline(false), 2600);
prev.current = state;
return () => clearTimeout(id);
}
prev.current = state;
}, [state]);
const visible = state !== 'online' || showOnline;
return (
<div className={cn('grid transition-all duration-300 ease-smooth', visible ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0', className)} aria-live="polite">
<div className="overflow-hidden">
<div
className={cn(
'mb-2 flex items-center gap-2.5 rounded-lg px-3 py-2 text-xs transition-colors duration-500',
state === 'online' ? 'bg-emerald-400/10 text-emerald-200' : state === 'reconnecting' ? 'bg-brand-500/10 text-brand-200' : 'bg-amber-400/10 text-amber-200',
)}
>
{state === 'online' ? <Wifi className="h-3.5 w-3.5 shrink-0" /> : state === 'reconnecting' ? <RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin" /> : <WifiOff className="h-3.5 w-3.5 shrink-0" />}
<span className="min-w-0 flex-1 truncate">
{state === 'online' && (queued ? `Back online · sending ${queued} queued` : 'Back online')}
{state === 'reconnecting' && `Reconnecting… attempt ${attempt}`}
{state === 'offline' && (
<>
You're offline. Retrying in <span className="font-mono tabular-nums">{secs}s</span>
{queued > 0 && <span className="text-amber-200/70"> · {queued} queued</span>}
</>
)}
</span>
{state === 'offline' && onRetryNow && (
<button onClick={onRetryNow} className="focus-ring shrink-0 rounded-md px-2 py-0.5 font-medium text-amber-100 hover:bg-white/10">
Try now
</button>
)}
</div>
</div>
</div>
);
}
type Queued = { id: number; text: string; status: 'queued' | 'sending' | 'sent' };
export function ConnectionStatusDemo() {
const later = useTimers();
const [state, setState] = useState<ConnectionState>('offline');
const [attempt, setAttempt] = useState(1);
const [nextAt, setNextAt] = useState<number | null>(() => Date.now() + 3000);
const [msgs, setMsgs] = useState<Queued[]>([
{ id: 1, text: 'Also check the EU numbers', status: 'queued' },
{ id: 2, text: 'and flag anything over 5%', status: 'queued' },
]);
const token = useRef(0);
const tryConnect = (n: number) => {
const id = ++token.current;
setState('reconnecting');
setAttempt(n);
later(1200, () => {
if (id !== token.current) return;
if (n < 2) {
setState('offline');
setNextAt(Date.now() + 4000);
later(4000, () => id === token.current && tryConnect(n + 1));
return;
}
setState('online');
setMsgs((m) => m.map((x) => ({ ...x, status: 'sending' })));
later(700, () => setMsgs((m) => m.map((x) => ({ ...x, status: 'sent' }))));
});
};
useEffect(() => {
const id = window.setTimeout(() => tryConnect(1), 3000);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const reset = () => {
token.current++;
setState('offline');
setAttempt(1);
setNextAt(Date.now() + 3000);
setMsgs((m) => m.map((x) => ({ ...x, status: 'queued' })));
later(3000, () => tryConnect(1));
};
const pending = msgs.filter((m) => m.status !== 'sent').length;
return (
<Thread
footer={
<>
<ConnectionStatus state={state} attempt={attempt} nextAttemptAt={nextAt} queued={pending} onRetryNow={() => tryConnect(attempt + 1)} />
<div className="flex items-center gap-2">
<div className="flex h-10 flex-1 items-center rounded-xl bg-ink-950/60 px-3 text-sm text-ink-500 shadow-hairline">Messages you send now will queue…</div>
<button onClick={reset} className="focus-ring h-10 shrink-0 rounded-xl px-3 text-xs text-ink-400 hover:bg-white/5 hover:text-ink-100">
Drop connection
</button>
</div>
</>
}
>
<UserMsg>Summarise churn by region for last quarter.</UserMsg>
<AssistantMsg>Churn rose to 3.1% overall, driven mostly by APAC self-serve plans. North America held steady at 2.4%…</AssistantMsg>
{msgs.map((m) => (
<div key={m.id} className="flex flex-col items-end gap-1">
<p className={cn('max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm transition-all duration-500', m.status === 'sent' ? 'bg-brand-500/90 text-white' : 'bg-white/5 text-ink-300 shadow-hairline')}>{m.text}</p>
<span className="flex items-center gap-1 text-[11px] text-ink-500">
{m.status === 'queued' && (
<>
<Clock className="h-3 w-3" /> Queued
</>
)}
{m.status === 'sending' && (
<>
<RefreshCw className="h-3 w-3 animate-spin" /> Sending
</>
)}
{m.status === 'sent' && (
<>
<Check className="h-3 w-3 text-emerald-400" /> Sent
</>
)}
</span>
</div>
))}
</Thread>
);
}components/niceui/ai/errors/shared.tsx
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
export const reducedMotion = () => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Seconds left until `target` (ms timestamp), ticking every 250ms. */
export function useCountdown(target: number | null) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (target == null) return;
const id = window.setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(id);
}, [target]);
return target == null ? 0 : Math.max(0, Math.ceil((target - now) / 1000));
}
export function useTimers() {
const ids = useRef<number[]>([]);
useEffect(() => () => ids.current.forEach(clearTimeout), []);
return (ms: number, fn: () => void) => void ids.current.push(window.setTimeout(fn, ms));
}
export const fmtClock = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
/** A compact chat thread frame so each state is shown in context. */
export function Thread({ title = 'Research assistant', children, footer, className }: { title?: string; children: ReactNode; footer?: ReactNode; className?: string }) {
return (
<div className={cn('mx-auto w-full max-w-xl overflow-hidden rounded-2xl bg-ink-900 shadow-card', className)}>
<div className="flex items-center gap-2 border-b border-white/5 px-4 py-2.5">
<span className="flex h-5 w-5 items-center justify-center rounded-md bg-brand-500/15 text-brand-300">
<Sparkles className="h-3 w-3" />
</span>
<span className="text-xs font-medium text-ink-200">{title}</span>
</div>
<div className="space-y-3 px-4 py-4">{children}</div>
{footer && <div className="border-t border-white/5 p-3">{footer}</div>}
</div>
);
}
export function UserMsg({ children }: { children: ReactNode }) {
return (
<div className="flex justify-end">
<p className="max-w-[85%] rounded-2xl rounded-br-md bg-brand-500/90 px-3.5 py-2 text-sm text-white">{children}</p>
</div>
);
}
export function AssistantMsg({ children }: { children: ReactNode }) {
return <p className="max-w-[90%] text-sm leading-relaxed text-ink-200">{children}</p>;
}
export function Composer({ disabled, placeholder = 'Ask anything…' }: { disabled?: boolean; placeholder?: string }) {
return (
<div className={cn('flex h-10 items-center rounded-xl bg-ink-950/60 px-3 text-sm text-ink-500 shadow-hairline transition-opacity', disabled && 'opacity-50')}>
{placeholder}
</div>
);
}