All components

Chat Template

Chat Template is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT other

What it is

Chat Template is a React and Tailwind CSS component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i lucide-react motion

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Chat Template

chat-data.ts
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;
chat-sidebar.tsx
"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;
chat-thread.tsx
"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;
index.tsx
"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";

Similar components