- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
A starting point for a conversational workspace, with a local demo and a configurable AI endpoint.
Chat StarterOpen full screen
Installation
Set up this registry, then run:
$ pnpm dlx shadcn@latest add @cobalt/chat-starter
Set AI_API_KEY in your server environment. You can also set AI_PROVIDER, CHAT_MODEL, and AI_BASE_URL to select a provider or compatible endpoint. The preview uses simulated replies and requires no key.
Before making the app public, set spending limits with your provider and rate limit /api/chat.
"use client";
import { useChat } from "@ai-sdk/react";
import { RiMenuLine } from "@remixicon/react";
import { DefaultChatTransport } from "ai";
import type { UIMessage } from "ai";
import { cn } from "cn";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AgentChatActions } from "@/components/blocks/chat-starter/components/agent-chat-actions";
import { AgentComposer } from "@/components/blocks/chat-starter/components/agent-chat-composer";
import { AgentChatHistory } from "@/components/blocks/chat-starter/components/agent-chat-history";
import type { ChatThreadSummary } from "@/components/blocks/chat-starter/components/agent-chat-history";
import { AgentMessage } from "@/components/blocks/chat-starter/components/agent-chat-message";
import {
createSwitchingTransport,
demoTransport,
} from "@/components/blocks/chat-starter/components/chat-starter-demo-transport";
import { starterNav } from "@/components/blocks/chat-starter/components/chat-starter-nav";
import {
DashboardSidebar,
DashboardSidebarDrawer,
} from "@/components/blocks/sidebar-01/components/dashboard-sidebar";
import { AgentThinking } from "@/components/ui/agent-thinking";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
/**
* A working chat app, wired to the `/api/chat` runtime.
*
* Built only from free components — the free `sidebar`, AgentThinking,
* ComposerLoader, Button — so the whole screen can ship in a public
* starter repo without giving away anything sold as Pro. It follows the Pro AI
* chat template's geometry (12px page frame, 16px gap, the 260px sidebar card,
* a large-radius chat surface) so a starter and a purchased template read as
* the same product.
*
* Any later chat screen that streams from a model should read the same
* message shape from the same endpoint, so it can replace this one without
* touching the backend.
*
* With `simulate`, the runtime probe and the network are skipped entirely and
* every send goes to the scripted transport, so a docs preview needs no key
* and makes no request.
*/
const ENDPOINT = "/api/chat";
const STORAGE_KEY = "chat-starter:agent-chat-threads";
/** Remembered so a reload of a keyless deploy lands back in the demo, not on the notice. */
const DEMO_KEY = "chat-starter:agent-chat-demo";
/** Enough history to be useful without letting localStorage grow unbounded. */
const MAX_THREADS = 30;
const SUGGESTIONS = [
"Explain what this starter does",
"Write a product update in three sentences",
"Give me five names for a scheduling app",
];
interface StoredThread extends ChatThreadSummary {
messages: UIMessage[];
/** Message id -> when it arrived, so a restored chat shows real times
* rather than reporting every message as having just landed. */
messageAt: Record<string, number>;
}
interface Probe {
status: "checking" | "ready" | "unconfigured";
provider: string | null;
/** Display name from the runtime ("Anthropic"), so the UI needs no list of its own. */
label: string | null;
model: string | null;
}
const atBottom = (node: HTMLElement) =>
node.scrollHeight - node.scrollTop - node.clientHeight < 64;
export function AgentChat({
className,
contained = false,
simulate = false,
basePath = "",
links,
popupClassName,
}: {
className?: string;
/** Docs preview mode: fill the preview frame's height instead of the
* viewport, and keep the phone nav drawer inside the frame. */
contained?: boolean;
/** Scripted replies, no network and no key — the docs preview. */
simulate?: boolean;
/** Prefix for the starter's page links; "" at the site root. */
basePath?: string;
/** Hrefs for other pages in the sidebar, by app shell key. */
links?: Partial<Record<string, string>>;
/** Theme scope for portalled menus. */
popupClassName?: string;
}) {
const [probe, setProbe] = useState<Probe>({
status: simulate ? "ready" : "checking",
provider: null,
label: null,
model: null,
});
const [input, setInput] = useState("");
const [navOpen, setNavOpen] = useState(false);
// The sidebar block ships its own sample navigation; the starter's own
// pages are linked from the header instead; see chat-starter-nav.
const [threads, setThreads] = useState<StoredThread[]>([]);
// Empty until the mount effect assigns one: an id generated during render
// would differ between server and client and break hydration.
const [activeId, setActiveId] = useState("");
/** Stamped on send, so render never calls Date.now(). */
const [activeUpdatedAt, setActiveUpdatedAt] = useState(0);
/** Set when the open thread is renamed. Without it the derived title
* (built from the first message) would immediately overwrite the name. */
const [activeTitle, setActiveTitle] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const titleRef = useRef<HTMLSpanElement>(null);
const columnRef = useRef<HTMLDivElement>(null);
/** When each message arrived, for the hover timestamp. */
const [messageAt, setMessageAt] = useState<Record<string, number>>({});
/** True once the transcript is scrolled at all. */
const [scrolledUnder, setScrolledUnder] = useState(false);
/** Whether the title and the message column actually overlap horizontally.
* On a wide screen the column is centred well clear of a short title, so
* text passing behind the header never touches it and a rule would be
* drawing a line under nothing. */
const [canClash, setCanClash] = useState(false);
/** Guards the writer: before the restore below runs, `threads` is empty,
* and persisting that would erase stored history on every page load. */
const [restored, setRestored] = useState(false);
// Demo mode: the visitor skipped the key notice. Only meaningful while the
// runtime reports no key; the moment a key exists, the real route answers.
const [demo, setDemo] = useState(simulate);
const demoActive = demo && (simulate || probe.status === "unconfigured");
const [transport] = useState(() =>
createSwitchingTransport(
new DefaultChatTransport({ api: ENDPOINT }),
demoTransport
)
);
useEffect(() => {
if (simulate) {
return;
}
try {
// eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
setDemo(window.localStorage.getItem(DEMO_KEY) === "on");
} catch {
// Storage can be blocked; the notice simply shows again.
}
}, [simulate]);
const enterDemo = () => {
setDemo(true);
try {
window.localStorage.setItem(DEMO_KEY, "on");
} catch {
// Nothing to remember without storage.
}
};
const { messages, sendMessage, setMessages, status, stop, error } = useChat({
transport,
});
const busy = status === "submitted" || status === "streaming";
// Reasoning models stream their thinking before any text, and the SDK flips
// status to "streaming" on that first non-text chunk. Gating the indicator on
// "submitted" alone therefore hides it seconds before a word appears, leaving
// an empty transcript. It stays up until real text exists.
const streamedText = useMemo(() => {
const last = messages.at(-1);
if (!last || last.role !== "assistant") {
return "";
}
return last.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}, [messages]);
const showThinking = busy && streamedText.length === 0;
/** The open chat as plain text, for the share control. */
const transcript = useMemo(
() =>
messages
.map(
(message) =>
`${message.role === "user" ? "You" : "Assistant"}: ${messageText(message)}`
)
.filter((line) => !line.endsWith(": "))
.join("\n\n"),
[messages]
);
// Ask the runtime what it has before the first message, so a deployment with
// no key renders setup steps instead of letting someone type into a dead box.
useEffect(() => {
if (simulate) {
return;
}
let cancelled = false;
fetch(ENDPOINT, { method: "GET" })
.then((res) => (res.ok ? res.json() : null))
.then(
(
data: {
configured?: boolean;
provider?: string;
providerLabel?: string;
model?: string;
} | null
) => {
if (cancelled) {
return;
}
// Only an explicit `configured: false` means "no key". A failed or
// unreadable probe says nothing about the key, and treating it as
// missing would hide the composer behind setup steps for a fault that
// has nothing to do with configuration.
setProbe({
status:
data && data.configured === false ? "unconfigured" : "ready",
provider: data?.provider ?? null,
label: data?.providerLabel ?? null,
model: data?.model ?? null,
});
}
)
.catch(() => {
// A failed probe shouldn't lock the composer — let the send attempt
// surface the real error rather than guessing the cause here.
if (!cancelled) {
setProbe({
status: "ready",
provider: null,
label: null,
model: null,
});
}
});
return () => {
cancelled = true;
};
}, [simulate]);
// Restore history, then open on a fresh thread rather than reviving the last
// one — landing mid-conversation after a reload is more surprising than
// useful, and the rail is right there. localStorage cannot be read during
// render (it does not exist while server-rendering), so this is the one
// place state has to be set from an effect.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
setActiveId(newThreadId());
setThreads(orderThreads(readThreads()));
setRestored(true);
}, []);
// The thread being typed into is derived rather than stored, so its title
// appears as soon as the first message lands without a state round-trip.
// The timestamp is stamped when a message is sent rather than read during
// render, so rendering stays pure and the badge does not drift each frame.
const liveThread: StoredThread | null = useMemo(
() =>
activeId && messages.length > 0
? {
id: activeId,
title: activeTitle ?? deriveTitle(messages),
updatedAt: activeUpdatedAt,
messages,
messageAt,
}
: null,
[activeId, messages, activeUpdatedAt, messageAt, activeTitle]
);
// Ordered by last message, never by what is open: selecting a thread must
// not move it, or the list reshuffles under the pointer as you browse it.
const allThreads = useMemo(() => {
const rest = threads.filter((thread) => thread.id !== activeId);
return orderThreads(liveThread ? [liveThread, ...rest] : rest);
}, [liveThread, threads, activeId]);
// Persist whenever the list settles. Held back during a stream so it is not
// rewritten on every token, and until the restore above has run so an empty
// first render cannot clobber stored history.
useEffect(() => {
if (!restored || busy) {
return;
}
writeThreads(allThreads);
}, [restored, busy, allThreads]);
// Arrival times come from outside React (they are a property of when the
// stream delivered each message), so they are recorded rather than derived.
useEffect(() => {
const unseen = messages.filter(
(message) => messageAt[message.id] === undefined
);
if (unseen.length === 0) {
return;
}
const now = Date.now();
// eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
setMessageAt((prev) => {
const next = { ...prev };
for (const message of unseen) {
next[message.id] = now;
}
return next;
});
}, [messages, messageAt]);
// Geometry, not a breakpoint: the title is as wide as its text, so whether
// it reaches the column depends on both and has to be measured.
useEffect(() => {
const measure = () => {
const title = titleRef.current?.getBoundingClientRect();
const column = columnRef.current?.getBoundingClientRect();
if (!title || !column) {
return;
}
// A 16px buffer so text never slides right up against the title.
setCanClash(title.right + 16 > column.left);
};
measure();
const observer = new ResizeObserver(measure);
if (scrollRef.current) {
observer.observe(scrollRef.current);
}
return () => observer.disconnect();
}, [messages.length, activeId]);
// Follow the newest message only while the reader is at the bottom; someone
// reading history is not pulled away. Leaving the bottom only counts when
// the reader did it, not our own smooth scroll still on its way.
useEffect(() => {
const node = scrollRef.current;
if (!node) {
return;
}
const release = () =>
window.requestAnimationFrame(() => {
pinnedRef.current = atBottom(node);
});
const events = ["wheel", "touchmove", "keydown"] as const;
for (const name of events) {
node.addEventListener(name, release, { passive: true });
}
return () => {
for (const name of events) {
node.removeEventListener(name, release);
}
};
}, [messages.length, activeId]);
useEffect(() => {
if (!pinnedRef.current) {
return;
}
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}, [messages, busy]);
const submit = (text: string) => {
const trimmed = text.trim();
if (!trimmed || busy) {
return;
}
setActiveUpdatedAt(Date.now());
sendMessage({ text: trimmed }, { body: { demo: demoActive } });
setInput("");
};
/** Folds the thread being left back into the stored list before switching.
* Its timestamp is carried over untouched, so leaving a thread never
* changes where it sits in the list. */
const keepLiveThread = useCallback(() => {
if (!liveThread) {
return;
}
setThreads((prev) =>
orderThreads([
liveThread,
...prev.filter((thread) => thread.id !== liveThread.id),
])
);
}, [liveThread]);
const startNewChat = useCallback(() => {
if (busy) {
return;
}
keepLiveThread();
setActiveId(newThreadId());
setActiveUpdatedAt(0);
setActiveTitle(null);
setMessageAt({});
setMessages([]);
setInput("");
}, [busy, keepLiveThread, setMessages]);
const renameThread = useCallback(
(id: string, title: string) => {
if (id === activeId) {
setActiveTitle(title);
}
setThreads((prev) =>
prev.map((thread) => (thread.id === id ? { ...thread, title } : thread))
);
},
[activeId]
);
const toggleUnread = useCallback((id: string) => {
setThreads((prev) =>
prev.map((thread) =>
thread.id === id ? { ...thread, unread: !thread.unread } : thread
)
);
}, []);
const deleteThread = useCallback(
(id: string) => {
const remaining = threads.filter((thread) => thread.id !== id);
setThreads(remaining);
// Deleting the thread you are reading leaves nothing to read, so the
// pane opens a fresh one rather than showing a transcript with no row.
if (id === activeId) {
setActiveId(newThreadId());
setActiveUpdatedAt(0);
setActiveTitle(null);
setMessageAt({});
setMessages([]);
setInput("");
}
},
[threads, activeId, setMessages]
);
/** Hands the stored chats back as a file. Nothing leaves the browser: the
* blob is built locally and revoked straight after the click. */
const exportThreads = useCallback(() => {
if (allThreads.length === 0) {
return;
}
const blob = new Blob([JSON.stringify(allThreads, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "chat-starter-chats.json";
// In the document because some browsers ignore click() on a detached
// anchor, and revoked a tick later rather than immediately: revoking in the
// same turn can kill the URL before the browser has read the blob, which
// saves an empty file or nothing at all.
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}, [allThreads]);
const selectThread = useCallback(
(id: string) => {
if (busy || id === activeId) {
return;
}
const thread = allThreads.find((candidate) => candidate.id === id);
if (!thread) {
return;
}
keepLiveThread();
// Reading a chat is what marks it read again.
if (thread.unread) {
toggleUnread(id);
}
setActiveId(id);
setActiveUpdatedAt(thread.updatedAt);
setActiveTitle(thread.title);
setMessageAt(thread.messageAt ?? {});
setMessages(thread.messages);
setInput("");
},
[busy, activeId, allThreads, keepLiveThread, toggleUnread, setMessages]
);
return (
<div
className={cn(
"relative flex w-full gap-4 overflow-hidden bg-background p-3",
contained ? "h-[var(--template-preview-height)]" : "h-dvh",
className
)}
>
<div className="hidden shrink-0 lg:flex">
<DashboardSidebar
items={starterNav(basePath, links)}
selected="chat"
announcement={false}
popupClassName={popupClassName}
linkComponent={Link}
/>
</div>
{/* Below lg the block's own drawer takes over, so the workspace does not
have to carry a second overlay. */}
<DashboardSidebarDrawer
items={starterNav(basePath, links)}
selected="chat"
announcement={false}
open={navOpen}
onOpenChange={setNavOpen}
popupClassName={popupClassName}
linkComponent={Link}
/>
<div className="relative flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
<Card
variant="muted"
radius={24}
size="flush"
className="relative h-full min-w-0 flex-1"
>
<header
className={cn(
"absolute inset-x-0 top-0 z-10 flex h-12 items-center gap-2 border-b px-4 pt-1.75 transition-colors",
// Overlaid rather than stacked above the transcript. Stacked,
// the scroll area began below it and text was cut off at the
// header's edge; overlaid, it runs to the container's own
// rounded top and is clipped by that instead.
// A rule only earns its place when the text both passes under
// the header AND the title is wide enough to be in its way.
// The border is always present but transparent, so appearing
// costs no height and nothing shifts.
// When it does clash the band frosts over, so the text passing
// behind it is obscured instead of running through the title.
// Tied to the same condition as the rule: with no clash the
// header stays fully transparent and the transcript reads all
// the way up to the card's edge.
scrolledUnder && canClash
? "border-(color:--border-subtle) bg-background/20 backdrop-blur-lg"
: "border-transparent"
)}
>
<Button
type="button"
variant="ghost"
size="icon-sm"
tooltip="Open navigation"
onClick={() => setNavOpen((open) => !open)}
className="lg:hidden"
>
<RiMenuLine aria-hidden />
</Button>
<span
ref={titleRef}
className="min-w-0 shrink truncate text-base leading-5.5 font-medium text-foreground"
>
{headerTitle(allThreads, activeId)}
</span>
<AgentChatActions
className="ms-auto"
transcript={transcript}
onExport={exportThreads}
onToggleUnread={() => toggleUnread(activeId)}
onDelete={() => deleteThread(activeId)}
disabled={messages.length === 0}
popupClassName={popupClassName}
/>
</header>
{probe.status === "unconfigured" && messages.length === 0 && !demo ? (
<SetupNotice onSkip={enterDemo} />
) : (
<>
<div
ref={scrollRef}
onScroll={(event) => {
const node = event.currentTarget;
setScrolledUnder(node.scrollTop > 0);
// Reaching the bottom pins again. Leaving it only counts
// when the reader did it (see the input handlers below), not
// our own smooth scroll still on its way.
if (atBottom(node)) {
pinnedRef.current = true;
}
}}
className="min-h-0 flex-1 overflow-y-auto scroll-smooth"
>
<div
ref={columnRef}
className={cn(
"mx-auto flex w-full max-w-3xl flex-col gap-5 px-4 pt-18 pb-6",
// With nothing to read, the prompt sits in the middle of
// the empty space rather than pinned under the header.
messages.length === 0 && "min-h-full justify-center"
)}
>
{messages.length === 0 ? (
<EmptyState onPick={submit} demo={demoActive} />
) : (
messages.map((message, index) => (
<AgentMessage
key={message.id}
role={message.role}
text={messageText(message)}
streaming={busy && index === messages.length - 1}
at={messageAt[message.id]}
/>
))
)}
{showThinking && (
<AgentThinking
variant="wave"
label="Thinking"
className="px-1"
/>
)}
{error && (
<p
role="alert"
className="px-1 text-sm leading-5 font-normal text-(color:--foreground-faint)"
>
Something went wrong. Check the server logs, then try
again.
</p>
)}
</div>
</div>
<div className="shrink-0 px-3 pb-3">
<div className="mx-auto w-full max-w-3xl">
<AgentComposer
value={input}
onValueChange={setInput}
onSubmit={() => submit(input)}
onStop={stop}
busy={busy}
model={probe.model}
provider={
demoActive ? "Demo mode" : (probe.label ?? probe.provider)
}
messageCount={messages.length}
/>
</div>
</div>
</>
)}
</Card>
{(probe.status !== "unconfigured" || messages.length > 0 || demo) && (
<AgentChatHistory
threads={allThreads}
activeId={activeId}
onSelect={selectThread}
onNewChat={startNewChat}
onRename={renameThread}
onToggleUnread={toggleUnread}
onDelete={deleteThread}
onExport={exportThreads}
basePath={basePath}
popupClassName={popupClassName}
disabled={busy}
className="hidden xl:flex"
/>
)}
</div>
</div>
);
}
function EmptyState({
onPick,
demo = false,
}: {
onPick: (text: string) => void;
demo?: boolean;
}) {
return (
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex flex-col gap-1">
<h2 className="text-xl leading-6.5 font-medium text-foreground">
What can I help with?
</h2>
<p className="text-sm leading-5 font-normal text-muted-foreground">
{demo
? "Demo mode: the answers are scripted. Add AI_API_KEY for a real model."
: "This chat runs against your own API key. History stays in this browser."}
</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
{SUGGESTIONS.map((suggestion) => (
<Button
key={suggestion}
type="button"
variant="outline"
shape="pill"
// oxlint-disable-next-line shadcn/no-restyle -- reference: suggestions are white pills with a shadow, no border, 14px side padding, secondary text
className="border-0 px-3.5 font-normal text-(--foreground-secondary,var(--muted-foreground)) dark:border-0"
onClick={() => onPick(suggestion)}
>
{suggestion}
</Button>
))}
</div>
</div>
);
}
/**
* Shown when the runtime reports no provider key. Deliberately specific about
* where the key goes, because the person seeing this has usually just clicked
* Deploy and has no context for where that setting lives.
*/
function SetupNotice({ onSkip }: { onSkip: () => void }) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Add an API key to start</CardTitle>
<CardDescription>
The chat is wired up and ready. It needs a model provider key before
it can answer.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ol className="flex list-decimal flex-col gap-1.5 ps-5 text-sm leading-5 font-normal text-muted-foreground">
<li>
Open your project on Vercel, then Settings, then Environment
Variables.
</li>
<li>
Add <code className="text-foreground">AI_API_KEY</code> with a key
from OpenAI, Anthropic, Google, OpenRouter, or Vercel AI Gateway.
</li>
<li>Redeploy.</li>
</ol>
<p className="text-xs leading-4 font-normal text-(color:--foreground-faint)">
Running locally? Put the same variable in <code>.env.local</code>{" "}
and restart the dev server.
</p>
</CardContent>
<CardFooter>
<Button className="w-full" onClick={onSkip}>
Skip, show me the demo
</Button>
</CardFooter>
</Card>
</div>
);
}
/** Newest message first, and trimmed from the oldest end. */
function orderThreads(threads: StoredThread[]) {
return [...threads]
.toSorted((a, b) => b.updatedAt - a.updatedAt)
.slice(0, MAX_THREADS);
}
/** The text parts of a message, joined. Reasoning and tool parts ride the
* same array and are ignored here; a richer screen would render them. */
function messageText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
}
function newThreadId() {
// randomUUID needs a secure context; a deployed starter always has one, but
// plain-http previews and older browsers do not.
return (
globalThis.crypto?.randomUUID?.() ??
`t-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
}
function deriveTitle(messages: UIMessage[]) {
const firstUser = messages.find((message) => message.role === "user");
const text = firstUser?.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
.trim();
if (!text) {
return "New chat";
}
return text.length > 48 ? `${text.slice(0, 48)}...` : text;
}
function headerTitle(threads: ChatThreadSummary[], activeId: string) {
return threads.find((thread) => thread.id === activeId)?.title ?? "New chat";
}
/** Storage is a convenience, never a source of truth — every access can throw
* (private browsing, disabled site data) and must not take the chat with it. */
function readThreads(): StoredThread[] {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return [];
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(thread): thread is StoredThread =>
typeof thread === "object" &&
thread !== null &&
typeof (thread as StoredThread).id === "string" &&
Array.isArray((thread as StoredThread).messages)
);
} catch {
return [];
}
}
function writeThreads(threads: StoredThread[]) {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(threads));
} catch {
// Quota or blocked storage: history is best-effort, the chat still works.
}
} On this page