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.

Chat Starter

A conversational app with a dashboard and account screens.

A starting point for a conversational workspace, with a local demo and a configurable AI endpoint.

Chat StarterOpen full screen

Installation

Set up this registry, then run:

$ pnpm dlx shadcn@latest add @cobalt/chat-starter

Set AI_API_KEY in your server environment. You can also set AI_PROVIDER, CHAT_MODEL, and AI_BASE_URL to select a provider or compatible endpoint. The preview uses simulated replies and requires no key.

Before making the app public, set spending limits with your provider and rate limit /api/chat.

"use client";

import { useChat } from "@ai-sdk/react";
import { RiMenuLine } from "@remixicon/react";
import { DefaultChatTransport } from "ai";
import type { UIMessage } from "ai";
import { cn } from "cn";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { AgentChatActions } from "@/components/blocks/chat-starter/components/agent-chat-actions";
import { AgentComposer } from "@/components/blocks/chat-starter/components/agent-chat-composer";
import { AgentChatHistory } from "@/components/blocks/chat-starter/components/agent-chat-history";
import type { ChatThreadSummary } from "@/components/blocks/chat-starter/components/agent-chat-history";
import { AgentMessage } from "@/components/blocks/chat-starter/components/agent-chat-message";
import {
  createSwitchingTransport,
  demoTransport,
} from "@/components/blocks/chat-starter/components/chat-starter-demo-transport";
import { starterNav } from "@/components/blocks/chat-starter/components/chat-starter-nav";
import {
  DashboardSidebar,
  DashboardSidebarDrawer,
} from "@/components/blocks/sidebar-01/components/dashboard-sidebar";
import { AgentThinking } from "@/components/ui/agent-thinking";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";

/**
 * A working chat app, wired to the `/api/chat` runtime.
 *
 * Built only from free components — the free `sidebar`, AgentThinking,
 * ComposerLoader, Button — so the whole screen can ship in a public
 * starter repo without giving away anything sold as Pro. It follows the Pro AI
 * chat template's geometry (12px page frame, 16px gap, the 260px sidebar card,
 * a large-radius chat surface) so a starter and a purchased template read as
 * the same product.
 *
 * Any later chat screen that streams from a model should read the same
 * message shape from the same endpoint, so it can replace this one without
 * touching the backend.
 *
 * With `simulate`, the runtime probe and the network are skipped entirely and
 * every send goes to the scripted transport, so a docs preview needs no key
 * and makes no request.
 */

const ENDPOINT = "/api/chat";
const STORAGE_KEY = "chat-starter:agent-chat-threads";
/** Remembered so a reload of a keyless deploy lands back in the demo, not on the notice. */
const DEMO_KEY = "chat-starter:agent-chat-demo";
/** Enough history to be useful without letting localStorage grow unbounded. */
const MAX_THREADS = 30;

const SUGGESTIONS = [
  "Explain what this starter does",
  "Write a product update in three sentences",
  "Give me five names for a scheduling app",
];

interface StoredThread extends ChatThreadSummary {
  messages: UIMessage[];
  /** Message id -> when it arrived, so a restored chat shows real times
   *  rather than reporting every message as having just landed. */
  messageAt: Record<string, number>;
}

interface Probe {
  status: "checking" | "ready" | "unconfigured";
  provider: string | null;
  /** Display name from the runtime ("Anthropic"), so the UI needs no list of its own. */
  label: string | null;
  model: string | null;
}

const atBottom = (node: HTMLElement) =>
  node.scrollHeight - node.scrollTop - node.clientHeight < 64;

export function AgentChat({
  className,
  contained = false,
  simulate = false,
  basePath = "",
  links,
  popupClassName,
}: {
  className?: string;
  /** Docs preview mode: fill the preview frame's height instead of the
   *  viewport, and keep the phone nav drawer inside the frame. */
  contained?: boolean;
  /** Scripted replies, no network and no key — the docs preview. */
  simulate?: boolean;
  /** Prefix for the starter's page links; "" at the site root. */
  basePath?: string;
  /** Hrefs for other pages in the sidebar, by app shell key. */
  links?: Partial<Record<string, string>>;
  /** Theme scope for portalled menus. */
  popupClassName?: string;
}) {
  const [probe, setProbe] = useState<Probe>({
    status: simulate ? "ready" : "checking",
    provider: null,
    label: null,
    model: null,
  });
  const [input, setInput] = useState("");
  const [navOpen, setNavOpen] = useState(false);
  // The sidebar block ships its own sample navigation; the starter's own
  // pages are linked from the header instead; see chat-starter-nav.
  const [threads, setThreads] = useState<StoredThread[]>([]);
  // Empty until the mount effect assigns one: an id generated during render
  // would differ between server and client and break hydration.
  const [activeId, setActiveId] = useState("");
  /** Stamped on send, so render never calls Date.now(). */
  const [activeUpdatedAt, setActiveUpdatedAt] = useState(0);
  /** Set when the open thread is renamed. Without it the derived title
   *  (built from the first message) would immediately overwrite the name. */
  const [activeTitle, setActiveTitle] = useState<string | null>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const pinnedRef = useRef(true);
  const titleRef = useRef<HTMLSpanElement>(null);
  const columnRef = useRef<HTMLDivElement>(null);
  /** When each message arrived, for the hover timestamp. */
  const [messageAt, setMessageAt] = useState<Record<string, number>>({});
  /** True once the transcript is scrolled at all. */
  const [scrolledUnder, setScrolledUnder] = useState(false);
  /** Whether the title and the message column actually overlap horizontally.
   *  On a wide screen the column is centred well clear of a short title, so
   *  text passing behind the header never touches it and a rule would be
   *  drawing a line under nothing. */
  const [canClash, setCanClash] = useState(false);
  /** Guards the writer: before the restore below runs, `threads` is empty,
   *  and persisting that would erase stored history on every page load. */
  const [restored, setRestored] = useState(false);

  // Demo mode: the visitor skipped the key notice. Only meaningful while the
  // runtime reports no key; the moment a key exists, the real route answers.
  const [demo, setDemo] = useState(simulate);
  const demoActive = demo && (simulate || probe.status === "unconfigured");
  const [transport] = useState(() =>
    createSwitchingTransport(
      new DefaultChatTransport({ api: ENDPOINT }),
      demoTransport
    )
  );
  useEffect(() => {
    if (simulate) {
      return;
    }
    try {
      // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
      setDemo(window.localStorage.getItem(DEMO_KEY) === "on");
    } catch {
      // Storage can be blocked; the notice simply shows again.
    }
  }, [simulate]);
  const enterDemo = () => {
    setDemo(true);
    try {
      window.localStorage.setItem(DEMO_KEY, "on");
    } catch {
      // Nothing to remember without storage.
    }
  };

  const { messages, sendMessage, setMessages, status, stop, error } = useChat({
    transport,
  });

  const busy = status === "submitted" || status === "streaming";

  // Reasoning models stream their thinking before any text, and the SDK flips
  // status to "streaming" on that first non-text chunk. Gating the indicator on
  // "submitted" alone therefore hides it seconds before a word appears, leaving
  // an empty transcript. It stays up until real text exists.
  const streamedText = useMemo(() => {
    const last = messages.at(-1);
    if (!last || last.role !== "assistant") {
      return "";
    }
    return last.parts
      .filter((part) => part.type === "text")
      .map((part) => part.text)
      .join("");
  }, [messages]);
  const showThinking = busy && streamedText.length === 0;

  /** The open chat as plain text, for the share control. */
  const transcript = useMemo(
    () =>
      messages
        .map(
          (message) =>
            `${message.role === "user" ? "You" : "Assistant"}: ${messageText(message)}`
        )
        .filter((line) => !line.endsWith(": "))
        .join("\n\n"),
    [messages]
  );

  // Ask the runtime what it has before the first message, so a deployment with
  // no key renders setup steps instead of letting someone type into a dead box.
  useEffect(() => {
    if (simulate) {
      return;
    }
    let cancelled = false;
    fetch(ENDPOINT, { method: "GET" })
      .then((res) => (res.ok ? res.json() : null))
      .then(
        (
          data: {
            configured?: boolean;
            provider?: string;
            providerLabel?: string;
            model?: string;
          } | null
        ) => {
          if (cancelled) {
            return;
          }
          // Only an explicit `configured: false` means "no key". A failed or
          // unreadable probe says nothing about the key, and treating it as
          // missing would hide the composer behind setup steps for a fault that
          // has nothing to do with configuration.
          setProbe({
            status:
              data && data.configured === false ? "unconfigured" : "ready",
            provider: data?.provider ?? null,
            label: data?.providerLabel ?? null,
            model: data?.model ?? null,
          });
        }
      )
      .catch(() => {
        // A failed probe shouldn't lock the composer — let the send attempt
        // surface the real error rather than guessing the cause here.
        if (!cancelled) {
          setProbe({
            status: "ready",
            provider: null,
            label: null,
            model: null,
          });
        }
      });
    return () => {
      cancelled = true;
    };
  }, [simulate]);

  // Restore history, then open on a fresh thread rather than reviving the last
  // one — landing mid-conversation after a reload is more surprising than
  // useful, and the rail is right there. localStorage cannot be read during
  // render (it does not exist while server-rendering), so this is the one
  // place state has to be set from an effect.
  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
    setActiveId(newThreadId());
    setThreads(orderThreads(readThreads()));
    setRestored(true);
  }, []);

  // The thread being typed into is derived rather than stored, so its title
  // appears as soon as the first message lands without a state round-trip.
  // The timestamp is stamped when a message is sent rather than read during
  // render, so rendering stays pure and the badge does not drift each frame.
  const liveThread: StoredThread | null = useMemo(
    () =>
      activeId && messages.length > 0
        ? {
            id: activeId,
            title: activeTitle ?? deriveTitle(messages),
            updatedAt: activeUpdatedAt,
            messages,
            messageAt,
          }
        : null,
    [activeId, messages, activeUpdatedAt, messageAt, activeTitle]
  );

  // Ordered by last message, never by what is open: selecting a thread must
  // not move it, or the list reshuffles under the pointer as you browse it.
  const allThreads = useMemo(() => {
    const rest = threads.filter((thread) => thread.id !== activeId);
    return orderThreads(liveThread ? [liveThread, ...rest] : rest);
  }, [liveThread, threads, activeId]);

  // Persist whenever the list settles. Held back during a stream so it is not
  // rewritten on every token, and until the restore above has run so an empty
  // first render cannot clobber stored history.
  useEffect(() => {
    if (!restored || busy) {
      return;
    }
    writeThreads(allThreads);
  }, [restored, busy, allThreads]);

  // Arrival times come from outside React (they are a property of when the
  // stream delivered each message), so they are recorded rather than derived.
  useEffect(() => {
    const unseen = messages.filter(
      (message) => messageAt[message.id] === undefined
    );
    if (unseen.length === 0) {
      return;
    }
    const now = Date.now();
    // eslint-disable-next-line react-hooks/set-state-in-effect -- restoring from localStorage after mount; there is no render-time source for it
    setMessageAt((prev) => {
      const next = { ...prev };
      for (const message of unseen) {
        next[message.id] = now;
      }
      return next;
    });
  }, [messages, messageAt]);

  // Geometry, not a breakpoint: the title is as wide as its text, so whether
  // it reaches the column depends on both and has to be measured.
  useEffect(() => {
    const measure = () => {
      const title = titleRef.current?.getBoundingClientRect();
      const column = columnRef.current?.getBoundingClientRect();
      if (!title || !column) {
        return;
      }
      // A 16px buffer so text never slides right up against the title.
      setCanClash(title.right + 16 > column.left);
    };
    measure();
    const observer = new ResizeObserver(measure);
    if (scrollRef.current) {
      observer.observe(scrollRef.current);
    }
    return () => observer.disconnect();
  }, [messages.length, activeId]);

  // Follow the newest message only while the reader is at the bottom; someone
  // reading history is not pulled away. Leaving the bottom only counts when
  // the reader did it, not our own smooth scroll still on its way.
  useEffect(() => {
    const node = scrollRef.current;
    if (!node) {
      return;
    }
    const release = () =>
      window.requestAnimationFrame(() => {
        pinnedRef.current = atBottom(node);
      });
    const events = ["wheel", "touchmove", "keydown"] as const;
    for (const name of events) {
      node.addEventListener(name, release, { passive: true });
    }
    return () => {
      for (const name of events) {
        node.removeEventListener(name, release);
      }
    };
  }, [messages.length, activeId]);

  useEffect(() => {
    if (!pinnedRef.current) {
      return;
    }
    scrollRef.current?.scrollTo({
      top: scrollRef.current.scrollHeight,
      behavior: "smooth",
    });
  }, [messages, busy]);

  const submit = (text: string) => {
    const trimmed = text.trim();
    if (!trimmed || busy) {
      return;
    }
    setActiveUpdatedAt(Date.now());
    sendMessage({ text: trimmed }, { body: { demo: demoActive } });
    setInput("");
  };

  /** Folds the thread being left back into the stored list before switching.
   *  Its timestamp is carried over untouched, so leaving a thread never
   *  changes where it sits in the list. */
  const keepLiveThread = useCallback(() => {
    if (!liveThread) {
      return;
    }
    setThreads((prev) =>
      orderThreads([
        liveThread,
        ...prev.filter((thread) => thread.id !== liveThread.id),
      ])
    );
  }, [liveThread]);

  const startNewChat = useCallback(() => {
    if (busy) {
      return;
    }
    keepLiveThread();
    setActiveId(newThreadId());
    setActiveUpdatedAt(0);
    setActiveTitle(null);
    setMessageAt({});
    setMessages([]);
    setInput("");
  }, [busy, keepLiveThread, setMessages]);

  const renameThread = useCallback(
    (id: string, title: string) => {
      if (id === activeId) {
        setActiveTitle(title);
      }
      setThreads((prev) =>
        prev.map((thread) => (thread.id === id ? { ...thread, title } : thread))
      );
    },
    [activeId]
  );

  const toggleUnread = useCallback((id: string) => {
    setThreads((prev) =>
      prev.map((thread) =>
        thread.id === id ? { ...thread, unread: !thread.unread } : thread
      )
    );
  }, []);

  const deleteThread = useCallback(
    (id: string) => {
      const remaining = threads.filter((thread) => thread.id !== id);
      setThreads(remaining);
      // Deleting the thread you are reading leaves nothing to read, so the
      // pane opens a fresh one rather than showing a transcript with no row.
      if (id === activeId) {
        setActiveId(newThreadId());
        setActiveUpdatedAt(0);
        setActiveTitle(null);
        setMessageAt({});
        setMessages([]);
        setInput("");
      }
    },
    [threads, activeId, setMessages]
  );

  /** Hands the stored chats back as a file. Nothing leaves the browser: the
   *  blob is built locally and revoked straight after the click. */
  const exportThreads = useCallback(() => {
    if (allThreads.length === 0) {
      return;
    }
    const blob = new Blob([JSON.stringify(allThreads, null, 2)], {
      type: "application/json",
    });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = "chat-starter-chats.json";
    // In the document because some browsers ignore click() on a detached
    // anchor, and revoked a tick later rather than immediately: revoking in the
    // same turn can kill the URL before the browser has read the blob, which
    // saves an empty file or nothing at all.
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(() => URL.revokeObjectURL(url), 0);
  }, [allThreads]);

  const selectThread = useCallback(
    (id: string) => {
      if (busy || id === activeId) {
        return;
      }
      const thread = allThreads.find((candidate) => candidate.id === id);
      if (!thread) {
        return;
      }
      keepLiveThread();
      // Reading a chat is what marks it read again.
      if (thread.unread) {
        toggleUnread(id);
      }
      setActiveId(id);
      setActiveUpdatedAt(thread.updatedAt);
      setActiveTitle(thread.title);
      setMessageAt(thread.messageAt ?? {});
      setMessages(thread.messages);
      setInput("");
    },
    [busy, activeId, allThreads, keepLiveThread, toggleUnread, setMessages]
  );

  return (
    <div
      className={cn(
        "relative flex w-full gap-4 overflow-hidden bg-background p-3",
        contained ? "h-[var(--template-preview-height)]" : "h-dvh",
        className
      )}
    >
      <div className="hidden shrink-0 lg:flex">
        <DashboardSidebar
          items={starterNav(basePath, links)}
          selected="chat"
          announcement={false}
          popupClassName={popupClassName}
          linkComponent={Link}
        />
      </div>

      {/* Below lg the block's own drawer takes over, so the workspace does not
          have to carry a second overlay. */}
      <DashboardSidebarDrawer
        items={starterNav(basePath, links)}
        selected="chat"
        announcement={false}
        open={navOpen}
        onOpenChange={setNavOpen}
        popupClassName={popupClassName}
        linkComponent={Link}
      />

      <div className="relative flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
        <Card
          variant="muted"
          radius={24}
          size="flush"
          className="relative h-full min-w-0 flex-1"
        >
          <header
            className={cn(
              "absolute inset-x-0 top-0 z-10 flex h-12 items-center gap-2 border-b px-4 pt-1.75 transition-colors",
              // Overlaid rather than stacked above the transcript. Stacked,
              // the scroll area began below it and text was cut off at the
              // header's edge; overlaid, it runs to the container's own
              // rounded top and is clipped by that instead.
              // A rule only earns its place when the text both passes under
              // the header AND the title is wide enough to be in its way.
              // The border is always present but transparent, so appearing
              // costs no height and nothing shifts.
              // When it does clash the band frosts over, so the text passing
              // behind it is obscured instead of running through the title.
              // Tied to the same condition as the rule: with no clash the
              // header stays fully transparent and the transcript reads all
              // the way up to the card's edge.
              scrolledUnder && canClash
                ? "border-(color:--border-subtle) bg-background/20 backdrop-blur-lg"
                : "border-transparent"
            )}
          >
            <Button
              type="button"
              variant="ghost"
              size="icon-sm"
              tooltip="Open navigation"
              onClick={() => setNavOpen((open) => !open)}
              className="lg:hidden"
            >
              <RiMenuLine aria-hidden />
            </Button>
            <span
              ref={titleRef}
              className="min-w-0 shrink truncate text-base leading-5.5 font-medium text-foreground"
            >
              {headerTitle(allThreads, activeId)}
            </span>

            <AgentChatActions
              className="ms-auto"
              transcript={transcript}
              onExport={exportThreads}
              onToggleUnread={() => toggleUnread(activeId)}
              onDelete={() => deleteThread(activeId)}
              disabled={messages.length === 0}
              popupClassName={popupClassName}
            />
          </header>

          {probe.status === "unconfigured" && messages.length === 0 && !demo ? (
            <SetupNotice onSkip={enterDemo} />
          ) : (
            <>
              <div
                ref={scrollRef}
                onScroll={(event) => {
                  const node = event.currentTarget;
                  setScrolledUnder(node.scrollTop > 0);
                  // Reaching the bottom pins again. Leaving it only counts
                  // when the reader did it (see the input handlers below), not
                  // our own smooth scroll still on its way.
                  if (atBottom(node)) {
                    pinnedRef.current = true;
                  }
                }}
                className="min-h-0 flex-1 overflow-y-auto scroll-smooth"
              >
                <div
                  ref={columnRef}
                  className={cn(
                    "mx-auto flex w-full max-w-3xl flex-col gap-5 px-4 pt-18 pb-6",
                    // With nothing to read, the prompt sits in the middle of
                    // the empty space rather than pinned under the header.
                    messages.length === 0 && "min-h-full justify-center"
                  )}
                >
                  {messages.length === 0 ? (
                    <EmptyState onPick={submit} demo={demoActive} />
                  ) : (
                    messages.map((message, index) => (
                      <AgentMessage
                        key={message.id}
                        role={message.role}
                        text={messageText(message)}
                        streaming={busy && index === messages.length - 1}
                        at={messageAt[message.id]}
                      />
                    ))
                  )}

                  {showThinking && (
                    <AgentThinking
                      variant="wave"
                      label="Thinking"
                      className="px-1"
                    />
                  )}

                  {error && (
                    <p
                      role="alert"
                      className="px-1 text-sm leading-5 font-normal text-(color:--foreground-faint)"
                    >
                      Something went wrong. Check the server logs, then try
                      again.
                    </p>
                  )}
                </div>
              </div>

              <div className="shrink-0 px-3 pb-3">
                <div className="mx-auto w-full max-w-3xl">
                  <AgentComposer
                    value={input}
                    onValueChange={setInput}
                    onSubmit={() => submit(input)}
                    onStop={stop}
                    busy={busy}
                    model={probe.model}
                    provider={
                      demoActive ? "Demo mode" : (probe.label ?? probe.provider)
                    }
                    messageCount={messages.length}
                  />
                </div>
              </div>
            </>
          )}
        </Card>

        {(probe.status !== "unconfigured" || messages.length > 0 || demo) && (
          <AgentChatHistory
            threads={allThreads}
            activeId={activeId}
            onSelect={selectThread}
            onNewChat={startNewChat}
            onRename={renameThread}
            onToggleUnread={toggleUnread}
            onDelete={deleteThread}
            onExport={exportThreads}
            basePath={basePath}
            popupClassName={popupClassName}
            disabled={busy}
            className="hidden xl:flex"
          />
        )}
      </div>
    </div>
  );
}

function EmptyState({
  onPick,
  demo = false,
}: {
  onPick: (text: string) => void;
  demo?: boolean;
}) {
  return (
    <div className="flex flex-col items-center gap-4 text-center">
      <div className="flex flex-col gap-1">
        <h2 className="text-xl leading-6.5 font-medium text-foreground">
          What can I help with?
        </h2>
        <p className="text-sm leading-5 font-normal text-muted-foreground">
          {demo
            ? "Demo mode: the answers are scripted. Add AI_API_KEY for a real model."
            : "This chat runs against your own API key. History stays in this browser."}
        </p>
      </div>
      <div className="flex flex-wrap justify-center gap-2">
        {SUGGESTIONS.map((suggestion) => (
          <Button
            key={suggestion}
            type="button"
            variant="outline"
            shape="pill"
            // oxlint-disable-next-line shadcn/no-restyle -- reference: suggestions are white pills with a shadow, no border, 14px side padding, secondary text
            className="border-0 px-3.5 font-normal text-(--foreground-secondary,var(--muted-foreground)) dark:border-0"
            onClick={() => onPick(suggestion)}
          >
            {suggestion}
          </Button>
        ))}
      </div>
    </div>
  );
}

/**
 * Shown when the runtime reports no provider key. Deliberately specific about
 * where the key goes, because the person seeing this has usually just clicked
 * Deploy and has no context for where that setting lives.
 */
function SetupNotice({ onSkip }: { onSkip: () => void }) {
  return (
    <div className="flex min-h-0 flex-1 items-center justify-center p-6">
      <Card className="max-w-md">
        <CardHeader>
          <CardTitle>Add an API key to start</CardTitle>
          <CardDescription>
            The chat is wired up and ready. It needs a model provider key before
            it can answer.
          </CardDescription>
        </CardHeader>
        <CardContent className="flex flex-col gap-3">
          <ol className="flex list-decimal flex-col gap-1.5 ps-5 text-sm leading-5 font-normal text-muted-foreground">
            <li>
              Open your project on Vercel, then Settings, then Environment
              Variables.
            </li>
            <li>
              Add <code className="text-foreground">AI_API_KEY</code> with a key
              from OpenAI, Anthropic, Google, OpenRouter, or Vercel AI Gateway.
            </li>
            <li>Redeploy.</li>
          </ol>
          <p className="text-xs leading-4 font-normal text-(color:--foreground-faint)">
            Running locally? Put the same variable in <code>.env.local</code>{" "}
            and restart the dev server.
          </p>
        </CardContent>
        <CardFooter>
          <Button className="w-full" onClick={onSkip}>
            Skip, show me the demo
          </Button>
        </CardFooter>
      </Card>
    </div>
  );
}

/** Newest message first, and trimmed from the oldest end. */
function orderThreads(threads: StoredThread[]) {
  return [...threads]
    .toSorted((a, b) => b.updatedAt - a.updatedAt)
    .slice(0, MAX_THREADS);
}

/** The text parts of a message, joined. Reasoning and tool parts ride the
 *  same array and are ignored here; a richer screen would render them. */
function messageText(message: UIMessage) {
  return message.parts
    .filter((part) => part.type === "text")
    .map((part) => part.text)
    .join("");
}

function newThreadId() {
  // randomUUID needs a secure context; a deployed starter always has one, but
  // plain-http previews and older browsers do not.
  return (
    globalThis.crypto?.randomUUID?.() ??
    `t-${Date.now()}-${Math.random().toString(16).slice(2)}`
  );
}

function deriveTitle(messages: UIMessage[]) {
  const firstUser = messages.find((message) => message.role === "user");
  const text = firstUser?.parts
    .filter((part) => part.type === "text")
    .map((part) => part.text)
    .join("")
    .trim();
  if (!text) {
    return "New chat";
  }
  return text.length > 48 ? `${text.slice(0, 48)}...` : text;
}

function headerTitle(threads: ChatThreadSummary[], activeId: string) {
  return threads.find((thread) => thread.id === activeId)?.title ?? "New chat";
}

/** Storage is a convenience, never a source of truth — every access can throw
 *  (private browsing, disabled site data) and must not take the chat with it. */
function readThreads(): StoredThread[] {
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    if (!raw) {
      return [];
    }
    const parsed: unknown = JSON.parse(raw);
    if (!Array.isArray(parsed)) {
      return [];
    }
    return parsed.filter(
      (thread): thread is StoredThread =>
        typeof thread === "object" &&
        thread !== null &&
        typeof (thread as StoredThread).id === "string" &&
        Array.isArray((thread as StoredThread).messages)
    );
  } catch {
    return [];
  }
}

function writeThreads(threads: StoredThread[]) {
  try {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(threads));
  } catch {
    // Quota or blocked storage: history is best-effort, the chat still works.
  }
}