Todos os componentes

Chat Template

Chat Template é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT other

O que é

Chat Template é um componente para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i lucide-react motion

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

Chat Template

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";

Componentes parecidos