For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

AI Chat 01

A coding workspace with conversations and a side preview.

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>
  );
}