Chat Template
Chat Template é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
other
O que é
Chat Template é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i lucide-react motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
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";