"use client";
import * as React from "react";
import {
Bars3Icon,
BookmarkIcon,
BookmarkSlashIcon,
ChatBubbleLeftIcon,
MagnifyingGlassIcon,
PencilIcon,
PlusIcon,
SparklesIcon,
TrashIcon,
XMarkIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import AIChat from "@/blocks/ai-chat/chat";
export type Conversation = {
id: string;
title: string;
updatedAt: number;
pinned?: boolean;
};
const HOUR = 3_600_000;
const seed: Conversation[] = [
{ id: "c1", title: "Streaming responses in Astro", updatedAt: Date.now() - 0.3 * HOUR, pinned: true },
{ id: "c2", title: "Design tokens for dark mode", updatedAt: Date.now() - 4 * HOUR },
{ id: "c3", title: "Explain islands architecture", updatedAt: Date.now() - 26 * HOUR },
{ id: "c4", title: "Fix the flaky Enter key test", updatedAt: Date.now() - 50 * HOUR },
{ id: "c5", title: "Draft launch announcement", updatedAt: Date.now() - 6 * 24 * HOUR },
];
/** `now` is null during SSR so relative times never mismatch on hydration. */
function relative(ts: number, now: number | null) {
if (now === null) return "";
const diff = now - ts;
if (diff < HOUR) return `${Math.max(1, Math.round(diff / 60_000))}m`;
if (diff < 24 * HOUR) return `${Math.round(diff / HOUR)}h`;
return `${Math.round(diff / (24 * HOUR))}d`;
}
export type ChatShellProps = {
conversations?: Conversation[];
className?: string;
};
export default function ChatShell({ conversations: initial = seed, className }: ChatShellProps) {
const [conversations, setConversations] = React.useState(initial);
const [activeId, setActiveId] = React.useState(initial[0]?.id ?? "");
const [query, setQuery] = React.useState("");
const [renaming, setRenaming] = React.useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const [now, setNow] = React.useState<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);
const active = conversations.find((c) => c.id === activeId);
const filtered = conversations.filter((c) => c.title.toLowerCase().includes(query.trim().toLowerCase()));
const pinned = filtered.filter((c) => c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
const recent = filtered.filter((c) => !c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
const createConversation = () => {
const c: Conversation = { id: `c-${Date.now().toString(36)}`, title: "New conversation", updatedAt: Date.now() };
setConversations((prev) => [c, ...prev]);
setActiveId(c.id);
setSidebarOpen(false);
};
const update = (id: string, patch: Partial<Conversation>) =>
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
const remove = (id: string) => {
setConversations((prev) => {
const next = prev.filter((c) => c.id !== id);
if (id === activeId) setActiveId(next[0]?.id ?? "");
return next;
});
};
const renderItem = (c: Conversation) => {
const isActive = c.id === activeId;
return (
<li key={c.id} className="group/item relative">
{renaming === c.id ? (
<form
className="px-1"
onSubmit={(e) => {
e.preventDefault();
const value = new FormData(e.currentTarget).get("title");
if (typeof value === "string" && value.trim()) update(c.id, { title: value.trim() });
setRenaming(null);
}}
>
<input
name="title"
autoFocus
defaultValue={c.title}
onBlur={(e) => {
if (e.target.value.trim()) update(c.id, { title: e.target.value.trim() });
setRenaming(null);
}}
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
className="h-9 w-full rounded-lg border bg-background px-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</form>
) : (
<button
type="button"
onClick={() => {
setActiveId(c.id);
setSidebarOpen(false);
}}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-sm transition-colors",
isActive ? "bg-brand-soft/60 font-medium text-brand dark:bg-brand-soft/50" : "text-foreground hover:bg-accent",
)}
>
<ChatBubbleLeftIcon size={14} className="flex shrink-0 opacity-60" />
<span className="min-w-0 flex-1 truncate">{c.title}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground group-hover/item:opacity-0">{relative(c.updatedAt, now)}</span>
</button>
)}
{renaming !== c.id ? (
<div className="absolute top-1/2 right-1 hidden -translate-y-1/2 items-center gap-0.5 rounded-md bg-background/90 p-0.5 shadow-xs backdrop-blur group-hover/item:flex">
<button type="button" aria-label={c.pinned ? "Unpin" : "Pin"} onClick={() => update(c.id, { pinned: !c.pinned })} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground">
{c.pinned ? <BookmarkSlashIcon size={12} className="flex" /> : <BookmarkIcon size={12} className="flex" />}
</button>
<button type="button" aria-label="Rename" onClick={() => setRenaming(c.id)} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground">
<PencilIcon size={12} className="flex" />
</button>
<button type="button" aria-label="Delete" onClick={() => remove(c.id)} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-red-600">
<TrashIcon size={12} className="flex" />
</button>
</div>
) : null}
</li>
);
};
return (
<div data-slot="chat-shell" className={cn("relative flex h-full min-h-0 w-full overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
{sidebarOpen ? <button type="button" aria-label="Close sidebar" className="absolute inset-0 z-20 bg-background/60 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} /> : null}
<aside
className={cn(
"absolute inset-y-0 left-0 z-30 flex w-72 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-transform md:static md:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-12 items-center justify-between gap-2 px-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
Chats
</div>
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="New conversation" onClick={createConversation}><PlusIcon size={16} className="flex" /></Button>
<Button size="icon-sm" variant="ghost" className="rounded-full md:hidden" aria-label="Close sidebar" onClick={() => setSidebarOpen(false)}><XMarkIcon size={16} className="flex" /></Button>
</div>
</div>
<div className="px-3 pb-2">
<label className="flex h-9 items-center gap-2 rounded-lg border bg-background px-2 text-sm text-muted-foreground focus-within:ring-[3px] focus-within:ring-ring/50">
<MagnifyingGlassIcon size={14} className="flex shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search chats" className="h-full w-full bg-transparent text-foreground outline-none placeholder:text-muted-foreground" />
</label>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{pinned.length ? (
<div className="mb-3">
<h4 className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Pinned</h4>
<ul className="flex flex-col gap-0.5">{pinned.map(renderItem)}</ul>
</div>
) : null}
<div>
<h4 className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Recent</h4>
{recent.length ? <ul className="flex flex-col gap-0.5">{recent.map(renderItem)}</ul> : <p className="px-2 py-4 text-xs text-muted-foreground">{query ? "No matches." : "No conversations yet."}</p>}
</div>
</nav>
<div className="flex items-center gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-foreground">PC</span>
<span className="truncate">pandacoderz · Pro plan</span>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3 md:hidden">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="Open sidebar" onClick={() => setSidebarOpen(true)}><Bars3Icon size={16} className="flex" /></Button>
<span className="truncate text-sm font-medium">{active?.title ?? "Chats"}</span>
</div>
<div className="min-h-0 flex-1 p-3">
{active ? (
<AIChat key={active.id} title={active.title} className="rounded-2xl" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-2xl border border-dashed text-center">
<p className="text-sm text-muted-foreground">No conversation selected.</p>
<Button size="sm" onClick={createConversation}><PlusIcon size={14} className="flex" /> New conversation</Button>
</div>
)}
</div>
</main>
</div>
);
}What’s inside
- Sidebar lists conversations in pinned and recent groups with search, hover actions, and inline rename.
- AI Chat renders the selected conversation. Each conversation gets its own instance via a React key.
- Mobile collapses the sidebar behind a toggle with a backdrop.
Installation
npx shadcn@latest add https://ui.spencerwueste.com/r/chat-shell.jsonpnpm dlx shadcn@latest add https://ui.spencerwueste.com/r/chat-shell.jsonyarn dlx shadcn@latest add https://ui.spencerwueste.com/r/chat-shell.jsonbunx --bun shadcn@latest add https://ui.spencerwueste.com/r/chat-shell.jsonInstall the dependencies:
npm install @heroicons-animated/react motionAdd the shadcn primitives it builds on:
npx shadcn@latest add buttonCopy the source into your project:
"use client";
import * as React from "react";
import {
Bars3Icon,
BookmarkIcon,
BookmarkSlashIcon,
ChatBubbleLeftIcon,
MagnifyingGlassIcon,
PencilIcon,
PlusIcon,
SparklesIcon,
TrashIcon,
XMarkIcon,
} from "@heroicons-animated/react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import AIChat from "@/blocks/ai-chat/chat";
export type Conversation = {
id: string;
title: string;
updatedAt: number;
pinned?: boolean;
};
const HOUR = 3_600_000;
const seed: Conversation[] = [
{ id: "c1", title: "Streaming responses in Astro", updatedAt: Date.now() - 0.3 * HOUR, pinned: true },
{ id: "c2", title: "Design tokens for dark mode", updatedAt: Date.now() - 4 * HOUR },
{ id: "c3", title: "Explain islands architecture", updatedAt: Date.now() - 26 * HOUR },
{ id: "c4", title: "Fix the flaky Enter key test", updatedAt: Date.now() - 50 * HOUR },
{ id: "c5", title: "Draft launch announcement", updatedAt: Date.now() - 6 * 24 * HOUR },
];
/** `now` is null during SSR so relative times never mismatch on hydration. */
function relative(ts: number, now: number | null) {
if (now === null) return "";
const diff = now - ts;
if (diff < HOUR) return `${Math.max(1, Math.round(diff / 60_000))}m`;
if (diff < 24 * HOUR) return `${Math.round(diff / HOUR)}h`;
return `${Math.round(diff / (24 * HOUR))}d`;
}
export type ChatShellProps = {
conversations?: Conversation[];
className?: string;
};
export default function ChatShell({ conversations: initial = seed, className }: ChatShellProps) {
const [conversations, setConversations] = React.useState(initial);
const [activeId, setActiveId] = React.useState(initial[0]?.id ?? "");
const [query, setQuery] = React.useState("");
const [renaming, setRenaming] = React.useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = React.useState(false);
const [now, setNow] = React.useState<number | null>(null);
React.useEffect(() => {
setNow(Date.now());
const id = setInterval(() => setNow(Date.now()), 60_000);
return () => clearInterval(id);
}, []);
const active = conversations.find((c) => c.id === activeId);
const filtered = conversations.filter((c) => c.title.toLowerCase().includes(query.trim().toLowerCase()));
const pinned = filtered.filter((c) => c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
const recent = filtered.filter((c) => !c.pinned).sort((a, b) => b.updatedAt - a.updatedAt);
const createConversation = () => {
const c: Conversation = { id: `c-${Date.now().toString(36)}`, title: "New conversation", updatedAt: Date.now() };
setConversations((prev) => [c, ...prev]);
setActiveId(c.id);
setSidebarOpen(false);
};
const update = (id: string, patch: Partial<Conversation>) =>
setConversations((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)));
const remove = (id: string) => {
setConversations((prev) => {
const next = prev.filter((c) => c.id !== id);
if (id === activeId) setActiveId(next[0]?.id ?? "");
return next;
});
};
const renderItem = (c: Conversation) => {
const isActive = c.id === activeId;
return (
<li key={c.id} className="group/item relative">
{renaming === c.id ? (
<form
className="px-1"
onSubmit={(e) => {
e.preventDefault();
const value = new FormData(e.currentTarget).get("title");
if (typeof value === "string" && value.trim()) update(c.id, { title: value.trim() });
setRenaming(null);
}}
>
<input
name="title"
autoFocus
defaultValue={c.title}
onBlur={(e) => {
if (e.target.value.trim()) update(c.id, { title: e.target.value.trim() });
setRenaming(null);
}}
onKeyDown={(e) => e.key === "Escape" && setRenaming(null)}
className="h-9 w-full rounded-lg border bg-background px-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</form>
) : (
<button
type="button"
onClick={() => {
setActiveId(c.id);
setSidebarOpen(false);
}}
className={cn(
"flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left text-sm transition-colors",
isActive ? "bg-brand-soft/60 font-medium text-brand dark:bg-brand-soft/50" : "text-foreground hover:bg-accent",
)}
>
<ChatBubbleLeftIcon size={14} className="flex shrink-0 opacity-60" />
<span className="min-w-0 flex-1 truncate">{c.title}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground group-hover/item:opacity-0">{relative(c.updatedAt, now)}</span>
</button>
)}
{renaming !== c.id ? (
<div className="absolute top-1/2 right-1 hidden -translate-y-1/2 items-center gap-0.5 rounded-md bg-background/90 p-0.5 shadow-xs backdrop-blur group-hover/item:flex">
<button type="button" aria-label={c.pinned ? "Unpin" : "Pin"} onClick={() => update(c.id, { pinned: !c.pinned })} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground">
{c.pinned ? <BookmarkSlashIcon size={12} className="flex" /> : <BookmarkIcon size={12} className="flex" />}
</button>
<button type="button" aria-label="Rename" onClick={() => setRenaming(c.id)} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground">
<PencilIcon size={12} className="flex" />
</button>
<button type="button" aria-label="Delete" onClick={() => remove(c.id)} className="flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-red-600">
<TrashIcon size={12} className="flex" />
</button>
</div>
) : null}
</li>
);
};
return (
<div data-slot="chat-shell" className={cn("relative flex h-full min-h-0 w-full overflow-hidden rounded-3xl border bg-background shadow-xs", className)}>
{sidebarOpen ? <button type="button" aria-label="Close sidebar" className="absolute inset-0 z-20 bg-background/60 backdrop-blur-sm md:hidden" onClick={() => setSidebarOpen(false)} /> : null}
<aside
className={cn(
"absolute inset-y-0 left-0 z-30 flex w-72 shrink-0 flex-col border-r bg-sidebar text-sidebar-foreground transition-transform md:static md:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className="flex h-12 items-center justify-between gap-2 px-3">
<div className="flex items-center gap-2 text-sm font-medium">
<span className="flex size-6 items-center justify-center rounded-md bg-brand text-primary-foreground"><SparklesIcon size={14} className="flex" /></span>
Chats
</div>
<div className="flex items-center gap-1">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="New conversation" onClick={createConversation}><PlusIcon size={16} className="flex" /></Button>
<Button size="icon-sm" variant="ghost" className="rounded-full md:hidden" aria-label="Close sidebar" onClick={() => setSidebarOpen(false)}><XMarkIcon size={16} className="flex" /></Button>
</div>
</div>
<div className="px-3 pb-2">
<label className="flex h-9 items-center gap-2 rounded-lg border bg-background px-2 text-sm text-muted-foreground focus-within:ring-[3px] focus-within:ring-ring/50">
<MagnifyingGlassIcon size={14} className="flex shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search chats" className="h-full w-full bg-transparent text-foreground outline-none placeholder:text-muted-foreground" />
</label>
</div>
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-3">
{pinned.length ? (
<div className="mb-3">
<h4 className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Pinned</h4>
<ul className="flex flex-col gap-0.5">{pinned.map(renderItem)}</ul>
</div>
) : null}
<div>
<h4 className="mb-1 px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Recent</h4>
{recent.length ? <ul className="flex flex-col gap-0.5">{recent.map(renderItem)}</ul> : <p className="px-2 py-4 text-xs text-muted-foreground">{query ? "No matches." : "No conversations yet."}</p>}
</div>
</nav>
<div className="flex items-center gap-2 border-t px-3 py-2.5 text-xs text-muted-foreground">
<span className="flex size-6 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-foreground">PC</span>
<span className="truncate">pandacoderz · Pro plan</span>
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3 md:hidden">
<Button size="icon-sm" variant="ghost" className="rounded-full" aria-label="Open sidebar" onClick={() => setSidebarOpen(true)}><Bars3Icon size={16} className="flex" /></Button>
<span className="truncate text-sm font-medium">{active?.title ?? "Chats"}</span>
</div>
<div className="min-h-0 flex-1 p-3">
{active ? (
<AIChat key={active.id} title={active.title} className="rounded-2xl" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 rounded-2xl border border-dashed text-center">
<p className="text-sm text-muted-foreground">No conversation selected.</p>
<Button size="sm" onClick={createConversation}><PlusIcon size={14} className="flex" /> New conversation</Button>
</div>
)}
</div>
</main>
</div>
);
}The registry item pulls in every component it depends on.
Usage
import ChatShell from "@/components/blocks/chat-shell/chat-shell";
export default function Page() {
return (
<div className="h-dvh p-4">
<ChatShell />
</div>
);
}Give the parent a bounded height. The block fills it and scrolls internally.
Going live
Replace the seeded conversations array with your own list and persist pin, rename, and delete to your API. To restore history per conversation, extend AIChat to accept initialMessages and pass them from the shell when a conversation is selected.