- 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
Worked for 2m 04s
Verified: the page returns 200 on the dev server, the redirect works, and the full production build passes. Say the word and I will open the pull request.
Worked for 5m 32s
Both detail pages are live and use the styles defined in the design file.
- New pages - /team and /billing are built from the shared DetailLayout: title and description, a scaled live preview of the real page inside a bordered frame, a Live templates section linking to the full-size pages, and an Included section listing everything in the bundle.
- Wider layout - DetailLayout now takes a width prop and the page shell grows from 1200px to 1560px (main column 900px to 1220px), so the dashboard fits without shrinking. The regular pages are unchanged.
Both pages return 200 on the dev server and lint is clean. Take a look, and tell me if you want the preview scale or the frame height adjusted.
Worked for 1m 48s
Done. The docs sidebar now has a Templates group listing the team and billing pages with a badge, and the library has a matching section. Everything is committed and pushed to main, and the deploy build went through cleanly.
- Read the project files
- Draft the light and dark tokens
- Wire up the theme toggle
- Add it to the registry
- Run lint and the production build
Installation
Set up the @cobalt registry as described in Installation, then add the block:
$ pnpm dlx shadcn@latest add @cobalt/ai-chat-01
This preview uses local sample data. Connect the workspace actions and messages to your application as needed.
Source
"use client";
import {
RiCheckLine,
RiFileCopyLine,
RiFolderLine,
RiLayoutLeftLine,
RiMoreLine,
RiSidebarUnfoldLine,
RiSparklingLine,
RiUploadLine,
} from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { AiChatComposer } from "@/components/blocks/ai-chat-01/components/ai-chat-composer";
import {
generatedImage,
initialActive,
initialGallery,
initialRepos,
seedTurns,
styles,
suggestions,
} from "@/components/blocks/ai-chat-01/components/ai-chat-data";
import type {
AiChatImage,
AiChatRepo,
AiChatTurn,
} from "@/components/blocks/ai-chat-01/components/ai-chat-data";
import { AiChatArt } from "@/components/blocks/ai-chat-01/components/ai-chat-gallery";
import { AiChatPanel } from "@/components/blocks/ai-chat-01/components/ai-chat-panel";
import { AiChatSessions } from "@/components/blocks/ai-chat-01/components/ai-chat-sessions";
import { AiChatTurnView } from "@/components/blocks/ai-chat-01/components/ai-chat-turn";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
const sidebarIn = "ai-chat-01-sidebar-in";
// Closing plays the opening in reverse and keeps the column mounted until it
// ends. Reduced motion hides it at once.
const sidebarOut =
"pointer-events-none -translate-x-1.5 opacity-0 transition-all duration-200 ease-out motion-reduce:hidden";
const panelIn = "ai-chat-01-panel-in";
const floating = "absolute inset-y-0 z-20 rounded-[24px] shadow-lg";
const initialTurns = Object.fromEntries(
initialRepos.flatMap((repo) =>
repo.sessions.map((session) => [session.id, seedTurns(session)])
)
);
/**
* The conversations and what they hold: repositories, sessions, turns and the
* gallery. `AiChat` calls it itself; call it yourself to put `AiChatSessions`
* in the shell sidebar and pass the result to `AiChat` as `chat`.
*/
export function useAiChat() {
const [repos, setRepos] = React.useState<AiChatRepo[]>(initialRepos);
const [openRepos, setOpenRepos] = React.useState(["northstar"]);
const [active, setActive] = React.useState(initialActive);
const [turns, setTurns] =
React.useState<Record<string, AiChatTurn[]>>(initialTurns);
const [gallery, setGallery] = React.useState(initialGallery);
const [style, setStyle] = React.useState(styles[0].name);
const counter = React.useRef(0);
const repo = repos.find((r) => r.sessions.some((s) => s.id === active));
const session = repo?.sessions.find((s) => s.id === active);
const kind = session?.kind ?? "code";
const list = turns[active] ?? [];
const working = list.some((turn) => !turn.done);
const nextId = (prefix: string) => {
counter.current += 1;
return `${prefix}${counter.current}`;
};
const select = (id: string) => setActive(id);
const send = (message: string) => {
const id = nextId("turn-");
setTurns((previous) => ({
...previous,
[active]: [
...(previous[active] ?? []),
{ id, prompt: message, kind, done: false, style },
],
}));
setRepos((previous) =>
previous.map((r) => ({
...r,
sessions: r.sessions.map((s) =>
s.id === active && s.title.startsWith("New agent")
? {
...s,
title:
message.length > 26 ? `${message.slice(0, 24)}…` : message,
}
: s
),
}))
);
};
const retry = (turn: AiChatTurn) => {
setTurns((previous) => ({
...previous,
[active]: [
...(previous[active] ?? []),
{ ...turn, id: nextId("turn-"), done: false, style },
],
}));
};
const finish = (id: string) => {
const turn = Object.values(turns)
.flat()
.find((t) => t.id === id);
setTurns((previous) =>
Object.fromEntries(
Object.entries(previous).map(([key, value]) => [
key,
value.map((t) => (t.id === id ? { ...t, done: true } : t)),
])
)
);
if (turn?.kind === "image") {
const image = generatedImage(turn.id, turn.prompt);
setGallery((previous) =>
previous.some((i) => i.id === image.id)
? previous
: [image, ...previous]
);
}
};
const create = () => {
const id = nextId("new-");
const home = repo?.id ?? repos[0].id;
setRepos((previous) =>
previous.map((r) =>
r.id === home
? {
...r,
sessions: [
{ id, title: "New agent", age: "now", kind: "code" },
...r.sessions,
],
}
: r
)
);
setTurns((previous) => ({ ...previous, [id]: [] }));
setOpenRepos((previous) =>
previous.includes(home) ? previous : [...previous, home]
);
select(id);
};
const duplicate = () => {
if (!repo || !session) {
return;
}
const id = nextId("copy-");
setRepos((previous) =>
previous.map((r) =>
r.id === repo.id
? {
...r,
sessions: [
{ ...session, id, title: `${session.title} copy`, age: "now" },
...r.sessions,
],
}
: r
)
);
setTurns((previous) => ({
...previous,
[id]: (previous[active] ?? []).map((t) => ({
...t,
id: `${id}-${t.id}`,
done: true,
})),
}));
select(id);
};
const archive = () => {
const rest = repos
.flatMap((r) => r.sessions)
.filter((s) => s.id !== active);
if (rest.length === 0) {
return;
}
const sibling = repo?.sessions.find((s) => s.id !== active) ?? rest[0];
setRepos((previous) =>
previous.map((r) => ({
...r,
sessions: r.sessions.filter((s) => s.id !== active),
}))
);
setActive(sibling.id);
};
const toggleRepo = (id: string) =>
setOpenRepos((previous) =>
previous.includes(id)
? previous.filter((r) => r !== id)
: [...previous, id]
);
return {
repos,
openRepos,
active,
turns,
gallery,
style,
setStyle,
repo,
session,
kind,
list,
working,
send,
retry,
finish,
create,
duplicate,
archive,
select,
toggleRepo,
};
}
export type AiChatState = ReturnType<typeof useAiChat>;
/**
* A coding workspace in three columns: conversations, the chat with its
* scripted agent run, and a side panel. Open a session that makes images and
* the panel becomes a gallery. It needs a parent with a height, and it works
* on its own or inside the shared app shell. Pass `chat` (from `useAiChat`)
* with `sessions={false}` when the shell sidebar shows the conversations.
*/
export function AiChat({
className,
popupClassName,
chat: external,
sessions = true,
}: {
className?: string;
popupClassName?: string;
chat?: AiChatState;
/** Set to false when the conversations live elsewhere, such as the sidebar. */
sessions?: boolean;
}) {
const own = useAiChat();
const chat = external ?? own;
const {
repos,
openRepos,
active,
gallery,
style,
setStyle,
repo,
session,
kind,
list,
working,
send,
retry,
finish,
create,
duplicate,
archive,
toggleRepo,
} = chat;
const [draft, setDraft] = React.useState("");
const [preview, setPreview] = React.useState<AiChatImage | null>(null);
const [sessionsPref, setSessionsOpen] = React.useState<boolean | null>(null);
const [panelPref, setPanelOpen] = React.useState<boolean | null>(null);
const [expanded, setExpanded] = React.useState(false);
const [shared, setShared] = React.useState(false);
const [width, setWidth] = React.useState(0);
const rootRef = React.useRef<HTMLDivElement>(null);
// Columns sit side by side when the box is wide enough and float over the
// chat when it is not. The first measurement decides what starts open.
React.useLayoutEffect(() => {
const root = rootRef.current;
if (!root) {
return;
}
setWidth(root.clientWidth);
const observer = new ResizeObserver(() => setWidth(root.clientWidth));
observer.observe(root);
return () => observer.disconnect();
}, []);
React.useEffect(() => {
if (!shared) {
return;
}
const timer = window.setTimeout(() => setShared(false), 2000);
return () => window.clearTimeout(timer);
}, [shared]);
// Until a toggle is used, each column follows the width of the box.
const sessionsOpen = sessions && (sessionsPref ?? width >= 896);
const panelOpen = panelPref ?? width >= 1024;
const sessionsStatic = width >= 896;
const panelStatic = width >= 1024;
const showSessions = sessionsOpen && !(expanded && panelStatic);
const [sessionsMounted, setSessionsMounted] = React.useState(showSessions);
React.useEffect(() => {
if (showSessions) {
setSessionsMounted(true);
return;
}
const timer = window.setTimeout(() => setSessionsMounted(false), 200);
return () => window.clearTimeout(timer);
}, [showSessions]);
const tip = (children: string) => ({ children, className: popupClassName });
const select = (id: string) => {
chat.select(id);
if (!sessionsStatic) {
setSessionsOpen(false);
}
};
return (
<div
ref={rootRef}
className={cn(
"@container relative flex h-full min-h-0 gap-3 text-foreground",
className
)}
>
{(showSessions || sessionsMounted) && (
<div
className={cn(
showSessions ? sidebarIn : sidebarOut,
"flex min-h-0",
sessionsStatic
? "w-60 shrink-0"
: `${floating} left-0 w-64 max-w-[85%]`
)}
>
<AiChatSessions
repos={repos}
open={openRepos}
active={active}
onToggleRepo={toggleRepo}
onSelect={select}
onNew={create}
className="w-full"
/>
</div>
)}
<Card
radius={24}
size="flush"
variant="raised"
className="min-w-0 flex-1"
aria-label="Conversation"
>
<header className="flex h-12 shrink-0 items-center gap-1 px-3">
{sessions && (
<Button
variant="ghost-muted"
size="icon-xs"
aria-pressed={showSessions}
tooltip={tip(
showSessions ? "Hide conversations" : "Show conversations"
)}
onClick={() => setSessionsOpen(!sessionsOpen)}
>
<RiLayoutLeftLine />
</Button>
)}
<Breadcrumb className="min-w-0 flex-1">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem className="min-w-0 shrink @max-lg:hidden">
<RiFolderLine className="size-4 shrink-0" />
<span className="truncate">{repo?.name}</span>
</BreadcrumbItem>
<BreadcrumbSeparator className="@max-lg:hidden" />
<BreadcrumbItem className="min-w-0">
<BreadcrumbPage className="truncate">
{session?.title}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Button
variant="ghost-muted"
size="icon-xs"
tooltip={tip(shared ? "Link copied" : "Share")}
onClick={() => {
navigator.clipboard
.writeText(`https://example.com/chat/${active}`)
.catch(() => {});
setShared(true);
}}
>
{shared ? <RiCheckLine /> : <RiUploadLine />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost-muted"
size="icon-xs"
tooltip={tip("More")}
/>
}
>
<RiMoreLine />
</DropdownMenuTrigger>
<DropdownMenuContent
size="sm"
align="end"
className={cn("w-52", popupClassName)}
>
<DropdownMenuItem onClick={duplicate}>
<RiFileCopyLine /> Duplicate session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard
.writeText(`https://example.com/chat/${active}`)
.catch(() => {});
setShared(true);
}}
>
<RiUploadLine /> Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={archive}>
Archive session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!panelOpen && (
<Button
variant="ghost-muted"
size="icon-xs"
tooltip={tip("Show panel")}
onClick={() => setPanelOpen(true)}
>
<RiSidebarUnfoldLine />
</Button>
)}
</header>
<MessageScrollerProvider
key={active}
autoScroll
defaultScrollPosition="end"
>
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport>
<MessageScrollerContent className="min-h-full justify-end gap-6 px-5 py-2">
{list.length === 0 ? (
<Empty variant="ghost" className="flex-1">
<EmptyHeader>
<EmptyMedia variant="icon">
<RiSparklingLine />
</EmptyMedia>
<EmptyTitle>What should we work on?</EmptyTitle>
<EmptyDescription>
Describe a task, or start with one of these.
</EmptyDescription>
</EmptyHeader>
<div className="flex flex-wrap justify-center gap-2 pt-2">
{suggestions.map((text) => (
<Button
key={text}
variant="outline"
size="sm"
onClick={() => setDraft(text)}
>
{text}
</Button>
))}
</div>
</Empty>
) : (
list.map((turn) => (
<MessageScrollerItem
key={`${active}:${turn.id}`}
messageId={`${active}:${turn.id}`}
className="flex flex-col gap-4"
>
<AiChatTurnView
turn={turn}
onDone={finish}
onRetry={retry}
onOpenImage={setPreview}
popupClassName={popupClassName}
/>
</MessageScrollerItem>
))
)}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
<div className="shrink-0 px-3 pb-3">
<AiChatComposer
value={draft}
onValueChange={setDraft}
onSend={send}
working={working}
popupClassName={popupClassName}
/>
</div>
</Card>
{panelOpen && (
<div
className={cn(
panelIn,
"flex min-h-0",
panelStatic
? expanded
? "min-w-0 flex-1"
: "w-[410px] shrink-0"
: `${floating} right-0 w-[min(26rem,92%)]`
)}
>
<AiChatPanel
kind={kind}
images={gallery}
imageStyle={style}
onStyleChange={setStyle}
onOpenImage={setPreview}
expanded={expanded}
onExpandedChange={setExpanded}
onClose={() => setPanelOpen(false)}
popupClassName={popupClassName}
className="w-full"
/>
</div>
)}
<Dialog
open={preview !== null}
onOpenChange={(open) => {
if (!open) {
setPreview(null);
}
}}
>
<DialogContent size="default" className={popupClassName}>
<DialogHeader>
<DialogTitle>{preview?.title}</DialogTitle>
</DialogHeader>
<div className="px-6">
{preview && (
<AiChatArt image={preview} className="rounded-[16px]" />
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}