Chat Template
Chat Template is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
other
What it is
Chat Template is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i lucide-react motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Chat Template
import type { AIApprovalOption } from "@/components/smoothui/ai-approval";
import type { AIDiffLine } from "@/components/smoothui/ai-diff";
import type { AIPromptAttachment } from "@/components/smoothui/ai-prompt-input";
import type { AIResponseCitation } from "@/components/smoothui/ai-response";
import type { AISource } from "@/components/smoothui/ai-sources";
import type { AISuggestion } from "@/components/smoothui/ai-suggestions";
import type { AITask } from "@/components/smoothui/ai-task-list";
/**
* A transcript turn.
*
* Assistant turns are a bag of optional parts rather than a block of markdown,
* because that is what an agent turn actually is: some thinking, some tool
* calls, some produced artifact, then prose. Adding a new part means adding one
* field here and one branch in `chat-thread.tsx` — that is the extension point
* as more of the AI set lands.
*/
export type ChatTurn =
| {
/** Files that rode along with the message. */
attachments?: AIPromptAttachment[];
from: "user";
id: string;
text: string;
timestamp: string;
}
| {
approval?: { options: AIApprovalOption[]; question: string };
artifact?: { code: string; language: string; title: string };
citations?: AIResponseCitation[];
diff?: { lines: AIDiffLine[]; title: string };
from: "assistant";
id: string;
reasoning?: string;
sources?: AISource[];
suggestions?: AISuggestion[];
tasks?: { label: string; tasks: AITask[] };
text?: string;
timestamp: string;
tool?: { args: string; name: string; result: string; summary: string };
};
export type ChatConversation = {
/** Sidebar grouping label — real products group by recency, so this does. */
group: string;
id: string;
title: string;
turns: ChatTurn[];
};
const RETIRE_FLAVOUR: ChatTurn[] = [
{
attachments: [
{ id: "a1", name: "sales-velocity-2026-q2.csv", size: 24_600 },
{ id: "a2", name: "flavour-review.pdf", size: 1_248_000 },
],
from: "user",
id: "r1",
text: "Compare mint chip to last summer and tell me which flavour to retire before Q4.",
timestamp: "14:31",
},
{
citations: [
{ id: "1", index: 1, title: "Sales velocity export, 2026 Q2" },
{ id: "2", index: 2, title: "Flavour performance review" },
],
from: "assistant",
id: "r2",
reasoning:
"Pulled three summers of sales, normalised for the two stores that opened last year, then compared weekend and weekday velocity before ranking the classics.",
sources: [
{
id: "s1",
snippet: "Weekly units by flavour and store, 2024–2026.",
title: "Sales velocity export, 2026 Q2",
url: "internal://warehouse/sales-velocity-2026-q2.csv",
},
{
id: "s2",
snippet: "Margin per scoop after the dairy contract renewal.",
title: "Flavour performance review",
url: "internal://docs/flavour-performance-review.md",
},
],
suggestions: [
{ id: "g1", label: "Draft the retirement announcement" },
{ id: "g2", label: "What replaces rocky road?" },
{ id: "g3", label: "Show the margin per scoop" },
],
text: "Mint chip is up 12% on last summer, and the whole gain sits on weekends [1]. Rocky road is the one to retire — down 6% year on year, lowest margin per scoop of the classics, and it does not recover in any store [2].",
timestamp: "14:32",
tool: {
args: '{ "flavours": ["mint-chip", "rocky-road"], "years": 3 }',
name: "query_sales",
result: "412 rows",
summary: "3 summers",
},
},
];
const SHIP_PRICE_CHANGE: ChatTurn[] = [
{
from: "user",
id: "p1",
text: "Raise the single scoop to 4.20 everywhere and stage the change.",
timestamp: "11:04",
},
{
approval: {
options: [
{ id: "ship", label: "Apply to all 14 stores" },
{
detail: "Rolls out to the two pilot stores only",
id: "pilot",
label: "Pilot first",
},
{ destructive: true, id: "discard", label: "Discard the change" },
],
question: "This updates live prices in 14 stores. Apply now?",
},
diff: {
lines: [
{ content: " scoops:", kind: "context", number: 12 },
{ content: "- single: 3.90", kind: "removed", number: 13 },
{ content: "+ single: 4.20", kind: "added", number: 13 },
{ content: " double: 6.40", kind: "context", number: 14 },
{ content: "- kids: 2.60", kind: "removed", number: 15 },
{ content: "+ kids: 2.80", kind: "added", number: 15 },
],
title: "config/pricing.yaml",
},
from: "assistant",
id: "p2",
tasks: {
label: "Plan",
tasks: [
{ id: "t1", label: "Read current price table", status: "done" },
{
children: [
{ id: "t2a", label: "Update scoop tiers", status: "done" },
{ id: "t2b", label: "Update kids portion", status: "done" },
],
id: "t2",
label: "Stage the new prices",
note: "2 files",
status: "done",
},
{ id: "t3", label: "Wait for approval", status: "running" },
{
id: "t4",
label: "Publish to stores",
note: "14 stores",
status: "pending",
},
],
},
text: "Staged. Kids portion moves with it to keep the ratio you set last spring — say the word and I will publish.",
timestamp: "11:05",
},
];
const SUMMER_CAMPAIGN: ChatTurn[] = [
{
from: "user",
id: "c1",
text: "Give me a summer campaign banner I can drop into the site.",
timestamp: "09:12",
},
{
artifact: {
code: `export const SummerBanner = () => (
<section className="rounded-3xl bg-gradient-to-br from-pink-200 to-sky-200 p-10">
<p className="text-sm uppercase tracking-widest">Summer 2026</p>
<h2 className="mt-2 text-4xl font-semibold">Two scoops, one price</h2>
<p className="mt-3 max-w-sm text-sm">
Every weekday before 5pm, all summer.
</p>
</section>
);`,
language: "tsx",
title: "SummerBanner.tsx",
},
from: "assistant",
id: "c2",
suggestions: [
{ id: "c-s1", label: "Make it dark mode aware" },
{ id: "c-s2", label: "Add a countdown" },
],
text: "Here it is, using the pink and sky pair from your brand tokens rather than new colours.",
timestamp: "09:13",
},
];
export const CONVERSATIONS: ChatConversation[] = [
{
group: "Today",
id: "retire-flavour",
title: "Which flavour to retire",
turns: RETIRE_FLAVOUR,
},
{
group: "Today",
id: "price-change",
title: "Stage the price change",
turns: SHIP_PRICE_CHANGE,
},
{
group: "Previous 7 days",
id: "summer-campaign",
title: "Summer campaign banner",
turns: SUMMER_CAMPAIGN,
},
];
/** Follow-ups offered on an empty thread, so the composer is never a blank stare. */
export const STARTER_SUGGESTIONS: AISuggestion[] = [
{ id: "st1", label: "Which store is growing fastest?" },
{ id: "st2", label: "Draft next week's staff rota" },
{ id: "st3", label: "Summarise last month's reviews" },
];
/**
* The reply the template plays back for anything you type.
*
* There is no model behind this. Everything below is a fixed script, which is
* the honest way to demo a chat surface: the components are the product, the
* answer is set dressing.
*/
export const SIMULATED_REPLY = {
citations: [
{ id: "1", index: 1, title: "Store operations log, week 30" },
] satisfies AIResponseCitation[],
reasoning:
"Checked the four stores that reported this week, then compared footfall against the same week last year before answering.",
sources: [
{
id: "sim-s1",
snippet: "Hourly footfall and till receipts, week 30.",
title: "Store operations log, week 30",
url: "internal://warehouse/ops-log-w30.csv",
},
] satisfies AISource[],
suggestions: [
{ id: "sim-g1", label: "Break it down by store" },
{ id: "sim-g2", label: "Compare with last summer" },
] satisfies AISuggestion[],
text: "Weekday afternoons are the soft spot: footfall holds but the average ticket drops about 18% after 3pm [1]. A two-scoop weekday offer is the cheapest lever you have before Q4.",
tool: {
args: '{ "week": 30, "stores": "all" }',
name: "query_operations",
result: "96 rows",
summary: "week 30",
},
} as const;
/** Offered by the composer's attach control — a picker stands in for a file dialog. */
export const ATTACHABLE_FILES: AIPromptAttachment[] = [
{ id: "f1", name: "ops-log-w30.csv", size: 18_400 },
{ id: "f2", name: "staff-rota-august.xlsx", size: 42_900 },
{ id: "f3", name: "supplier-invoice-3312.pdf", size: 268_000 },
];
export const MODELS = [
{ id: "opus-5", label: "Opus 5", note: "Best for planning" },
{ id: "sonnet-5", label: "Sonnet 5", note: "Fast, everyday work" },
{ id: "haiku-4-5", label: "Haiku 4.5", note: "Cheapest" },
] as const;
export const CONTEXT_LIMIT = 200_000;
"use client";
import { cn } from "@/lib/utils";
import SiriOrb from "@/components/smoothui/siri-orb";
import {
LogOut,
Moon,
PanelLeftClose,
PanelLeftOpen,
Plus,
Search,
Settings,
Sun,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChatConversation } from "./chat-data";
/** A real photograph, so the footer is a person and not a lettered circle. */
const USER_AVATAR =
"https://ik.imagekit.io/16u211libb/avatar-educalvolpz.jpeg?tr=w-64,h-64";
export type ChatSidebarProps = {
activeId: string;
className?: string;
/** Icon rail instead of the full pane. Keeps navigation reachable. */
collapsed?: boolean;
conversations: ChatConversation[];
onNewChat: () => void;
onSelect: (id: string) => void;
onToggleCollapsed?: () => void;
};
export const ChatSidebar = ({
activeId,
className,
collapsed = false,
conversations,
onNewChat,
onSelect,
onToggleCollapsed,
}: ChatSidebarProps) => {
const [query, setQuery] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
// The search filters for real. A search box that does nothing is the kind of
// fake content this template exists to avoid.
const groups = useMemo(() => {
const needle = query.trim().toLowerCase();
const matching = needle
? conversations.filter((conversation) =>
conversation.title.toLowerCase().includes(needle)
)
: conversations;
const byGroup = new Map<string, ChatConversation[]>();
for (const conversation of matching) {
const bucket = byGroup.get(conversation.group) ?? [];
bucket.push(conversation);
byGroup.set(conversation.group, bucket);
}
return [...byGroup.entries()];
}, [conversations, query]);
if (collapsed) {
return (
<aside
className={cn(
"flex h-full w-[4.5rem] shrink-0 flex-col items-center gap-2 border-border/60 border-r bg-muted/60 py-3",
className
)}
>
<SiriOrb size="22px" state="idle" />
<RailButton
icon={<PanelLeftOpen aria-hidden="true" size={16} />}
label="Expand sidebar"
onClick={onToggleCollapsed}
/>
<RailButton
icon={<Plus aria-hidden="true" size={16} />}
label="New chat"
onClick={onNewChat}
/>
<RailButton
icon={<Search aria-hidden="true" size={16} />}
label="Search chats"
onClick={() => {
onToggleCollapsed?.();
// Expanding and focusing in one action, so the icon is a shortcut
// rather than a two-step detour.
requestAnimationFrame(() => searchRef.current?.focus());
}}
/>
<div className="mt-auto">
<AccountMenu align="rail" />
</div>
</aside>
);
}
return (
<aside
className={cn(
"flex h-full w-[17rem] shrink-0 flex-col gap-3 border-border/60 border-r bg-muted/60 p-3",
className
)}
>
<div className="flex items-center justify-between gap-2 px-1">
<span className="flex items-center gap-2 font-medium text-sm">
<SiriOrb size="22px" state="idle" />
Scoop Assistant
</span>
<button
aria-label="Collapse sidebar"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onToggleCollapsed}
type="button"
>
<PanelLeftClose aria-hidden="true" size={16} />
</button>
</div>
<button
className="flex items-center gap-2 rounded-xl border border-border/60 bg-background px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
onClick={onNewChat}
type="button"
>
<Plus aria-hidden="true" size={15} />
New chat
</button>
{/* Icon and field share one flex row rather than absolute-positioning the
icon over padding: the gap is then a single value instead of two that
have to be kept in sync, and `type="search"` cannot push the text away
with its own intrinsic padding. */}
<label className="flex items-center gap-2 rounded-xl border border-transparent bg-muted px-2.5 py-2 transition-colors focus-within:border-border focus-within:bg-background">
<span className="sr-only">Search chats</span>
<Search
aria-hidden="true"
className="shrink-0 text-muted-foreground"
size={14}
/>
<input
className="w-full appearance-none bg-transparent text-sm outline-none placeholder:text-muted-foreground"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search chats"
ref={searchRef}
type="search"
value={query}
/>
</label>
<nav className="-mx-1 flex-1 overflow-y-auto px-1">
{groups.length === 0 && (
<p className="px-2 py-6 text-center text-muted-foreground text-xs">
No chats match “{query}”.
</p>
)}
{groups.map(([group, items]) => (
<div className="mb-3" key={group}>
<p className="px-2 pt-1 pb-1.5 font-medium text-[0.7rem] text-muted-foreground/80 uppercase tracking-wide">
{group}
</p>
<ul className="flex flex-col gap-0.5">
{items.map((conversation) => (
<li key={conversation.id}>
<button
aria-current={
conversation.id === activeId ? "page" : undefined
}
className={cn(
"w-full truncate rounded-lg px-2 py-2 text-left text-sm transition-colors",
conversation.id === activeId
? "bg-background text-foreground shadow-black/5 shadow-xs"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => onSelect(conversation.id)}
type="button"
>
{conversation.title}
</button>
</li>
))}
</ul>
</div>
))}
</nav>
<div className="border-border/60 border-t pt-3">
<AccountMenu align="pane" />
</div>
</aside>
);
};
const RailButton = ({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
}) => (
<button
aria-label={label}
className="rounded-lg p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
title={label}
type="button"
>
{icon}
</button>
);
/**
* The account menu, with the theme switch inside it.
*
* It flips the `dark` class on the document root rather than shipping a theme
* provider: that is the one contract every Tailwind setup already has, so the
* control works the moment the template is installed instead of after wiring.
*/
const AccountMenu = ({ align }: { align: "pane" | "rail" }) => {
const [isOpen, setIsOpen] = useState(false);
const [isDark, setIsDark] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
const onPointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
const toggleTheme = () => {
const next = !document.documentElement.classList.contains("dark");
document.documentElement.classList.toggle("dark", next);
setIsDark(next);
};
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={isOpen}
aria-haspopup="menu"
className={cn(
"flex w-full items-center gap-2 rounded-xl text-left transition-colors hover:bg-muted",
align === "pane" ? "p-1.5" : "justify-center p-1"
)}
onClick={() => setIsOpen((open) => !open)}
type="button"
>
<img
alt="Edu Calvo"
className="size-7 shrink-0 rounded-full object-cover"
height={28}
src={USER_AVATAR}
width={28}
/>
{align === "pane" && (
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">Edu Calvo</span>
<span className="block text-muted-foreground text-xs">
Pro plan
</span>
</span>
)}
</button>
{isOpen ? (
<div
className="absolute bottom-full left-0 z-10 mb-2 w-52 origin-bottom-left overflow-hidden rounded-xl border border-border/60 bg-background p-1 shadow-black/10 shadow-lg"
role="menu"
>
<MenuItem
icon={
isDark ? (
<Sun aria-hidden="true" size={14} />
) : (
<Moon aria-hidden="true" size={14} />
)
}
label={isDark ? "Light mode" : "Dark mode"}
onClick={toggleTheme}
/>
<MenuItem
icon={<Settings aria-hidden="true" size={14} />}
label="Settings"
/>
<MenuItem
icon={<LogOut aria-hidden="true" size={14} />}
label="Sign out"
/>
</div>
) : null}
</div>
);
};
const MenuItem = ({
icon,
label,
onClick,
}: {
icon: React.ReactNode;
label: string;
onClick?: () => void;
}) => (
<button
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
onClick={onClick}
role="menuitem"
type="button"
>
{icon}
{label}
</button>
);
export default ChatSidebar;
"use client";
import { cn } from "@/lib/utils";
import AIApproval from "@/components/smoothui/ai-approval";
import AIArtifact from "@/components/smoothui/ai-artifact";
import AIContextMeter from "@/components/smoothui/ai-context-meter";
import AIConversation from "@/components/smoothui/ai-conversation";
import type { AIState } from "@/components/smoothui/ai-core";
import AIDiff from "@/components/smoothui/ai-diff";
import AILoader from "@/components/smoothui/ai-loader";
import AIMessage from "@/components/smoothui/ai-message";
import AIPromptInput, {
type AIPromptAttachment,
} from "@/components/smoothui/ai-prompt-input";
import AIReasoning from "@/components/smoothui/ai-reasoning";
import AIResponse from "@/components/smoothui/ai-response";
import AISources from "@/components/smoothui/ai-sources";
import AISuggestions from "@/components/smoothui/ai-suggestions";
import AITaskList from "@/components/smoothui/ai-task-list";
import AIToolCall from "@/components/smoothui/ai-tool-call";
import SiriOrb from "@/components/smoothui/siri-orb";
import { ChevronDown, PanelLeftOpen, Paperclip } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ATTACHABLE_FILES,
type ChatTurn,
CONTEXT_LIMIT,
MODELS,
SIMULATED_REPLY,
STARTER_SUGGESTIONS,
} from "./chat-data";
const THINK_MS = 900;
const TOOL_MS = 1900;
const STREAM_INTERVAL_MS = 45;
/** Rough English ratio, good enough for a meter that is showing pressure. */
const CHARS_PER_TOKEN = 4;
const SYSTEM_TOKENS = 1800;
const TOKENS_PER_SOURCE = 9200;
const TOKENS_PER_ATTACHMENT = 6400;
type LivePhase = "idle" | "thinking" | "tool" | "streaming";
const formatClock = (date: Date) =>
`${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
export type ChatThreadProps = {
className?: string;
/** Opens the conversation list on narrow screens, where it has no column. */
onOpenSidebar?: () => void;
title: string;
turns: ChatTurn[];
};
export const ChatThread = ({
className,
onOpenSidebar,
title,
turns,
}: ChatThreadProps) => {
const [localTurns, setLocalTurns] = useState<ChatTurn[]>([]);
const [phase, setPhase] = useState<LivePhase>("idle");
const [streamed, setStreamed] = useState("");
const [draftAttachments, setDraftAttachments] = useState<
AIPromptAttachment[]
>([]);
const [model, setModel] = useState<string>(MODELS[0].label);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const clearTimers = useCallback(() => {
for (const timer of timers.current) {
clearTimeout(timer);
}
timers.current = [];
}, []);
useEffect(() => clearTimers, [clearTimers]);
useEffect(() => {
if (phase !== "streaming") {
return;
}
const words = SIMULATED_REPLY.text.split(" ");
let index = 0;
const interval = setInterval(() => {
index += 1;
setStreamed(words.slice(0, index).join(" "));
if (index >= words.length) {
clearInterval(interval);
setPhase("idle");
setLocalTurns((current) => [
...current,
{
citations: [...SIMULATED_REPLY.citations],
from: "assistant",
id: `live-a-${current.length}`,
reasoning: SIMULATED_REPLY.reasoning,
sources: [...SIMULATED_REPLY.sources],
suggestions: [...SIMULATED_REPLY.suggestions],
text: SIMULATED_REPLY.text,
timestamp: formatClock(new Date()),
tool: { ...SIMULATED_REPLY.tool },
},
]);
setStreamed("");
}
}, STREAM_INTERVAL_MS);
return () => clearInterval(interval);
}, [phase]);
const send = (value: string) => {
const draft = value.trim();
if (!draft || phase !== "idle") {
return;
}
clearTimers();
const attachments = draftAttachments;
setDraftAttachments([]);
setLocalTurns((current) => [
...current,
{
attachments: attachments.length > 0 ? attachments : undefined,
from: "user",
id: `live-u-${current.length}`,
text: draft,
timestamp: formatClock(new Date()),
},
]);
setPhase("thinking");
// Stand-ins for a request's stages. No model runs here.
timers.current.push(setTimeout(() => setPhase("tool"), THINK_MS));
timers.current.push(setTimeout(() => setPhase("streaming"), TOOL_MS));
};
const stop = () => {
clearTimers();
setPhase("idle");
setStreamed("");
};
// Memoised because the token breakdown below depends on it; a fresh array each
// render would make that memo pointless.
const allTurns = useMemo(
() => [...turns, ...localTurns],
[turns, localTurns]
);
const isBusy = phase !== "idle";
const state: AIState = phase === "streaming" ? "streaming" : "thinking";
// Derived from what is actually on screen, so an empty chat reads as empty
// instead of inheriting a fixture's 67k. A fixed number here was a lie the
// moment you pressed "New chat".
const breakdown = useMemo(() => {
// Everything the model would actually have been sent, not just the prose:
// a reasoning trace and an artifact's source are the bulk of a real turn.
const transcript =
allTurns.reduce((total, turn) => {
if (turn.from === "user") {
return total + turn.text.length;
}
return (
total +
(turn.text?.length ?? 0) +
(turn.reasoning?.length ?? 0) +
(turn.artifact?.code.length ?? 0) +
(turn.tool ? turn.tool.args.length + turn.tool.result.length : 0) +
(turn.diff?.lines.reduce(
(chars, line) => chars + line.content.length,
0
) ?? 0)
);
}, 0) / CHARS_PER_TOKEN;
const retrieved = allTurns.reduce(
(total, turn) =>
total +
(turn.from === "assistant" && turn.sources
? turn.sources.length * TOKENS_PER_SOURCE
: 0) +
(turn.from === "user" && turn.attachments
? turn.attachments.length * TOKENS_PER_ATTACHMENT
: 0),
0
);
return [
{ label: "System", tokens: SYSTEM_TOKENS },
{
label: "Transcript",
tokens: Math.round(transcript + streamed.length / CHARS_PER_TOKEN),
},
...(retrieved > 0
? [{ label: "Retrieved files", tokens: retrieved }]
: []),
];
}, [allTurns, streamed]);
const used = breakdown.reduce((total, item) => total + item.tokens, 0);
return (
<section className={cn("flex min-w-0 flex-1 flex-col", className)}>
<header className="flex items-center gap-3 border-border/60 border-b px-4 py-2.5">
{/* The only way into the conversation list on a phone, where the sidebar
has no column of its own. */}
{onOpenSidebar ? (
<button
aria-label="Open conversations"
className="-ml-1 flex cursor-pointer items-center rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground md:hidden"
onClick={onOpenSidebar}
type="button"
>
<PanelLeftOpen aria-hidden="true" size={16} />
</button>
) : null}
<h2 className="min-w-0 flex-1 truncate font-medium text-sm">{title}</h2>
<AIContextMeter
breakdown={breakdown}
limit={CONTEXT_LIMIT}
used={used}
/>
</header>
<AIConversation
className="flex-1 px-4"
contentKey={`${allTurns.length}-${streamed.length}-${phase}`}
>
{/* `px-2`, not decoration: the scroller clips at its padding box, and the
orb avatar sat flush against that edge — its glow extends past its
own 26px box, so the left of it was being shaved off. */}
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col gap-5 px-2 py-5">
{allTurns.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-10 text-center">
<SiriOrb size="64px" state="idle" />
<p className="text-muted-foreground text-sm">
Ask about sales, stores or staffing.
</p>
<AISuggestions
onSelect={(suggestion) => send(suggestion.label)}
suggestions={STARTER_SUGGESTIONS}
/>
</div>
)}
{allTurns.map((turn) => (
<ChatTurnView key={turn.id} onSuggestion={send} turn={turn} />
))}
{isBusy && (
<AIMessage
avatar={<SiriOrb size="26px" state={state} />}
bubble={false}
from="assistant"
>
<div className="flex flex-col gap-3">
<AIReasoning collapseWhenDone isStreaming>
{SIMULATED_REPLY.reasoning}
</AIReasoning>
{phase !== "thinking" && (
<AIToolCall
args={<code>{SIMULATED_REPLY.tool.args}</code>}
name={SIMULATED_REPLY.tool.name}
result={<span>{SIMULATED_REPLY.tool.result}</span>}
status={phase === "tool" ? "running" : "success"}
summary={SIMULATED_REPLY.tool.summary}
/>
)}
{phase === "streaming" ? (
<AIResponse
citations={[...SIMULATED_REPLY.citations]}
isStreaming
text={streamed}
/>
) : (
<AILoader label="Thinking" showElapsed variant="dots" />
)}
</div>
</AIMessage>
)}
</div>
</AIConversation>
<div className="border-border/60 border-t px-4 py-3">
<div className="mx-auto w-full max-w-2xl">
<AIPromptInput
attachments={draftAttachments}
maxLength={2000}
onAttach={() => {
// Stands in for a file dialog: adds the next unattached file, so
// the control does something real instead of miming it.
setDraftAttachments((current) => {
const next = ATTACHABLE_FILES.find(
(file) => !current.some((item) => item.id === file.id)
);
return next ? [...current, next] : current;
});
}}
onRemoveAttachment={(id) =>
setDraftAttachments((current) =>
current.filter((file) => file.id !== id)
)
}
onStop={stop}
onSubmit={send}
placeholder="Ask anything about the shop…"
state={isBusy ? "streaming" : "idle"}
>
<ModelPicker onSelect={setModel} value={model} />
</AIPromptInput>
<p className="pt-2 text-center text-muted-foreground text-xs">
Simulated responses. Nothing is sent anywhere.
</p>
</div>
</div>
</section>
);
};
/**
* One transcript turn.
*
* Every assistant part is optional and rendered in the order an agent produces
* it: thinking, tools, plan, changes, artifact, prose, then what it read. New AI
* components slot in as one more branch here.
*/
const ChatTurnView = ({
onSuggestion,
turn,
}: {
onSuggestion: (value: string) => void;
turn: ChatTurn;
}) => {
if (turn.from === "user") {
return (
<AIMessage copyText={turn.text} from="user" timestamp={turn.timestamp}>
<span className="flex flex-col gap-2">
{turn.text}
{turn.attachments && turn.attachments.length > 0 && (
<span className="flex flex-wrap justify-end gap-1.5">
{turn.attachments.map((file) => (
<span
className="flex items-center gap-1.5 rounded-lg bg-background/15 px-2 py-1 text-xs"
key={file.id}
>
<Paperclip aria-hidden="true" size={11} />
{file.name}
<span className="opacity-70">{formatBytes(file.size)}</span>
</span>
))}
</span>
)}
</span>
</AIMessage>
);
}
return (
<div className="flex flex-col gap-3">
<AIMessage
avatar={<SiriOrb size="26px" state="done" />}
bubble={false}
copyText={turn.text}
from="assistant"
onRetry={() => {
// A replayed transcript has nothing to retry against.
}}
onVote={() => {
// Demo only — no telemetry leaves the page.
}}
timestamp={turn.timestamp}
>
<div className="flex flex-col gap-3">
{turn.reasoning ? (
<AIReasoning collapseWhenDone duration={4}>
{turn.reasoning}
</AIReasoning>
) : null}
{turn.tool ? (
<AIToolCall
args={<code>{turn.tool.args}</code>}
name={turn.tool.name}
result={<span>{turn.tool.result}</span>}
status="success"
summary={turn.tool.summary}
/>
) : null}
{turn.tasks ? (
<AITaskList label={turn.tasks.label} tasks={turn.tasks.tasks} />
) : null}
{turn.diff ? (
<AIDiff lines={turn.diff.lines} title={turn.diff.title} />
) : null}
{turn.artifact ? (
<AIArtifact
code={
<pre className="whitespace-pre-wrap">{turn.artifact.code}</pre>
}
copyText={turn.artifact.code}
preview={<SummerBannerPreview />}
title={turn.artifact.title}
/>
) : null}
{turn.text ? (
<AIResponse citations={turn.citations} text={turn.text} />
) : null}
{turn.sources ? <AISources sources={turn.sources} /> : null}
</div>
</AIMessage>
{turn.approval ? (
<AIApproval
onDecide={() => {
// Demo only.
}}
options={turn.approval.options}
question={turn.approval.question}
/>
) : null}
{turn.suggestions ? (
<AISuggestions
label="Follow-ups"
onSelect={(suggestion) => onSuggestion(suggestion.label)}
suggestions={turn.suggestions}
/>
) : null}
</div>
);
};
const KIB = 1024;
const MIB = KIB * KIB;
const formatBytes = (size?: number) => {
if (!size) {
return "";
}
return size >= MIB
? `${(size / MIB).toFixed(1)} MB`
: `${Math.round(size / KIB)} KB`;
};
/**
* Model picker for the composer.
*
* Small enough to keep local: the template should not drag a popover library in
* for one menu, and the pattern is the same one the sidebar's account menu uses.
*/
const ModelPicker = ({
onSelect,
value,
}: {
onSelect: (label: string) => void;
value: string;
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) {
return;
}
const onPointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("pointerdown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("pointerdown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [isOpen]);
return (
<div className="relative" ref={containerRef}>
<button
aria-expanded={isOpen}
aria-haspopup="menu"
className="flex items-center gap-1 rounded-lg px-2 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setIsOpen((open) => !open)}
type="button"
>
{value}
<ChevronDown aria-hidden="true" size={12} />
</button>
{isOpen ? (
<div
className="absolute bottom-full left-0 z-10 mb-2 w-52 overflow-hidden rounded-xl border border-border/60 bg-background p-1 shadow-black/10 shadow-lg"
role="menu"
>
{MODELS.map((option) => (
<button
className={cn(
"flex w-full flex-col rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted",
option.label === value
? "text-foreground"
: "text-muted-foreground"
)}
key={option.id}
onClick={() => {
onSelect(option.label);
setIsOpen(false);
}}
role="menuitem"
type="button"
>
<span className="text-sm">{option.label}</span>
<span className="text-muted-foreground text-xs">
{option.note}
</span>
</button>
))}
</div>
) : null}
</div>
);
};
/** The artifact's rendered pane — the real banner, not a grey box. */
const SummerBannerPreview = () => (
<section className="rounded-2xl bg-gradient-to-br from-pink-200 to-sky-200 p-6 text-neutral-900">
<p className="text-[0.65rem] uppercase tracking-widest">Summer 2026</p>
<h3 className="mt-1.5 font-semibold text-2xl">Two scoops, one price</h3>
<p className="mt-2 max-w-sm text-sm">
Every weekday before 5pm, all summer.
</p>
</section>
);
export default ChatThread;
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { type ChatConversation, CONVERSATIONS } from "./chat-data";
import ChatSidebar from "./chat-sidebar";
import ChatThread from "./chat-thread";
const NEW_CHAT_ID = "__new__";
/** No overshoot: a drawer that bounces past its edge reads as a bug. */
const DRAWER_SPRING = { bounce: 0, duration: 0.3, type: "spring" as const };
const SCRIM_FADE = { duration: 0.2 };
export type ChatTemplateProps = {
className?: string;
/** Which conversation opens first. Defaults to the newest one. */
defaultConversationId?: string;
/** Swap in your own transcripts. The shape is `ChatConversation`. */
conversations?: ChatConversation[];
};
/**
* A full chat surface — sidebar, thread, composer — wired to a fixed script.
*
* There is no model behind it and no network call anywhere: every reply is
* scripted in `chat-data.ts`. Point the composer at your own endpoint and the
* rest of the template is already the product.
*/
const ChatTemplate = ({
className,
conversations = CONVERSATIONS,
defaultConversationId,
}: ChatTemplateProps) => {
const [activeId, setActiveId] = useState(
defaultConversationId ?? conversations[0]?.id ?? NEW_CHAT_ID
);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
// Narrow screens have no column for the list, so it arrives as a drawer.
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const shouldReduceMotion = useReducedMotion();
const open = (id: string) => {
setActiveId(id);
setIsDrawerOpen(false);
};
const active = conversations.find(
(conversation) => conversation.id === activeId
);
return (
<div
className={cn(
"relative flex h-full min-h-0 w-full overflow-hidden bg-background text-foreground",
className
)}
>
{/* Collapses to an icon rail rather than disappearing, so navigation stays
one click away. */}
<ChatSidebar
activeId={activeId}
className="hidden md:flex"
collapsed={!isSidebarOpen}
conversations={conversations}
onNewChat={() => open(NEW_CHAT_ID)}
onSelect={open}
onToggleCollapsed={() => setIsSidebarOpen((isOpen) => !isOpen)}
/>
{/* Below `md` the same list slides over the thread. Without it there is no
way to change conversation on a phone, which is most of what a chat
app's navigation is for.
Both children are keyed: `AnimatePresence` tracks its direct children by
key, and without one it never runs the enter or the exit — the panel
just sat parked off-screen. */}
<AnimatePresence>
{isDrawerOpen ? (
<>
<motion.button
animate={{ opacity: 1 }}
aria-label="Close conversations"
className="absolute inset-0 z-20 cursor-default bg-foreground/20 md:hidden"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
key="chat-drawer-scrim"
onClick={() => setIsDrawerOpen(false)}
transition={shouldReduceMotion ? { duration: 0 } : SCRIM_FADE}
type="button"
/>
{/* Reduced motion keeps the fade and drops the travel, rather than
removing the transition altogether. */}
<motion.div
animate={{ opacity: 1, x: 0 }}
className="absolute inset-y-0 left-0 z-30 flex md:hidden"
exit={
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: "-100%" }
}
initial={
shouldReduceMotion ? { opacity: 0 } : { opacity: 1, x: "-100%" }
}
key="chat-drawer-panel"
transition={shouldReduceMotion ? SCRIM_FADE : DRAWER_SPRING}
>
<ChatSidebar
activeId={activeId}
className="h-full bg-background shadow-black/10 shadow-xl"
collapsed={false}
conversations={conversations}
onNewChat={() => open(NEW_CHAT_ID)}
onSelect={open}
onToggleCollapsed={() => setIsDrawerOpen(false)}
/>
</motion.div>
</>
) : null}
</AnimatePresence>
<ChatThread
key={activeId}
onOpenSidebar={() => setIsDrawerOpen(true)}
title={active?.title ?? "New chat"}
turns={active?.turns ?? []}
/>
</div>
);
};
export default ChatTemplate;
export type { ChatConversation, ChatTurn } from "./chat-data";