Context Full New
A context-window warning that compacts the chat into a summary, or starts a new chat carrying that summary.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-context-full.json02
Or copy the source below (2 files). Dependencies: lucide-react Pricing research.
Source
components/niceui/ai/errors/ContextFull.tsx
import { useEffect, useRef, useState } from 'react';
import { ArrowRight, Check, FileText, Layers, Loader2, MessageSquarePlus } from 'lucide-react';
import { cn } from '@/lib/cn';
import { AssistantMsg, Composer, Thread, UserMsg, reducedMotion } from './shared';
export type ContextFullProps = {
/** Tokens in use and the model's window. */
used: number;
limit: number;
compacting?: boolean;
onCompact: () => void;
onNewChat: () => void;
className?: string;
};
const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
export function ContextFull({ used, limit, compacting, onCompact, onNewChat, className }: ContextFullProps) {
const pct = Math.min(100, (used / limit) * 100);
const hot = pct > 90;
return (
<div className={cn('overflow-hidden rounded-xl shadow-hairline transition-colors duration-700', hot ? 'bg-amber-400/[0.05]' : 'bg-emerald-400/[0.04]', className)}>
<div className="flex items-start gap-3 p-3.5">
<span className={cn('mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-700', hot ? 'bg-amber-400/15 text-amber-300' : 'bg-emerald-400/15 text-emerald-300')}>
<Layers className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink-50">{hot ? 'This chat is almost out of memory' : 'Conversation compacted'}</p>
<p className="mt-0.5 text-xs leading-relaxed text-ink-400">
{hot ? 'Older messages will start to drop out of context. Compact it to keep going, or carry a summary into a new chat.' : 'Earlier turns were folded into a summary the assistant can still use.'}
</p>
<div className="mt-2.5 flex items-center gap-2.5">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/5">
<div className={cn('h-full rounded-full transition-[width,background-color] duration-[1400ms] ease-smooth', hot ? 'bg-amber-400' : 'bg-emerald-400')} style={{ width: `${pct}%` }} />
</div>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-ink-400">
{k(used)} / {k(limit)}
</span>
</div>
</div>
</div>
{hot && (
<div className="flex flex-wrap gap-2 border-t border-white/5 bg-black/20 px-3.5 py-2.5">
<button onClick={onCompact} disabled={compacting} className="focus-ring inline-flex h-8 items-center gap-1.5 rounded-lg bg-brand-500 px-3 text-xs font-medium text-white hover:bg-brand-400 disabled:opacity-70">
{compacting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Layers className="h-3.5 w-3.5" />}
{compacting ? 'Summarising 46 messages…' : 'Compact conversation'}
</button>
<button onClick={onNewChat} disabled={compacting} className="focus-ring inline-flex h-8 items-center gap-1.5 rounded-lg bg-white/5 px-3 text-xs font-medium text-ink-100 hover:bg-white/10 disabled:opacity-50">
<MessageSquarePlus className="h-3.5 w-3.5" /> New chat with summary
</button>
</div>
)}
</div>
);
}
export function ContextFullDemo() {
const [mode, setMode] = useState<'full' | 'compacting' | 'compacted' | 'new'>('full');
const [used, setUsed] = useState(196_000);
const timer = useRef(0);
useEffect(() => () => clearTimeout(timer.current), []);
const compact = () => {
setMode('compacting');
timer.current = window.setTimeout(
() => {
setUsed(41_000);
setMode('compacted');
},
reducedMotion() ? 0 : 1400,
);
};
if (mode === 'new')
return (
<Thread title="New chat" footer={<Composer />}>
<div className="rounded-xl bg-white/[0.03] p-3.5 shadow-hairline animate-fade-up">
<p className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wider text-ink-500">
<FileText className="h-3.5 w-3.5" /> Carried over from "Pricing research"
</p>
<ul className="mt-2 space-y-1 text-xs leading-relaxed text-ink-300">
<li>• Comparing 6 competitors' seat vs usage pricing; usage wins for teams under 20.</li>
<li>• Decision pending: free tier cap at 3 projects or 1,000 runs.</li>
<li>• Next: draft the pricing page copy and FAQ.</li>
</ul>
</div>
<AssistantMsg>I've got the summary of our earlier chat. Want me to start on the pricing page copy?</AssistantMsg>
<button onClick={() => (setUsed(196_000), setMode('full'))} className="focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-ink-500 hover:text-ink-200">
Reset demo <ArrowRight className="h-3 w-3" />
</button>
</Thread>
);
return (
<Thread title="Pricing research" footer={<Composer />}>
<UserMsg>Compare that against the three competitors we looked at on Monday.</UserMsg>
<AssistantMsg>Pulling Monday's notes together. Of the three, two price per seat and one on usage…</AssistantMsg>
<ContextFull used={used} limit={200_000} compacting={mode === 'compacting'} onCompact={compact} onNewChat={() => setMode('new')} />
{mode === 'compacted' && (
<p className="flex items-center gap-1.5 text-xs text-emerald-300/80 animate-fade-up">
<Check className="h-3.5 w-3.5" /> 155k tokens freed. Nothing you said was deleted from your history.
<button onClick={() => (setUsed(196_000), setMode('full'))} className="focus-ring ml-auto rounded-md px-2 py-0.5 text-ink-500 hover:text-ink-200">
Reset
</button>
</p>
)}
</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>
);
}