Skip to content
All examples

Assistant

Conversation history, answers with cited sources and code, suggested prompts and a composer with attachments.

Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/assistant.json
components/assistant/assistant.tsx
"use client"

import * as React from "react"
import {
  ArrowUpIcon,
  CheckIcon,
  CopyIcon,
  FileTextIcon,
  GlobeIcon,
  PanelLeftIcon,
  PaperclipIcon,
  RefreshCwIcon,
  SearchIcon,
  SquarePenIcon,
  TelescopeIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
  XIcon,
} from "lucide-react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button, type ButtonProps } from "@/components/ui/button"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"
import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { Spinner } from "@/components/ui/spinner"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

type Source = { title: string; site: string; kind: "web" | "workspace" }

/**
 * A paragraph, a list or a code sample. Text may use **bold**, `code` and
 * [n], a citation of the answer's nth source.
 */
type Block = string | { list: string[] } | { code: string; language: string }

/** A question and Kestrel's answer to it. */
type Turn = {
  question: string
  files?: string[]
  /** How the answer was made, shown next to Kestrel's name. */
  meta: string
  answer: Block[]
  sources?: Source[]
  /** What Kestrel is doing while the answer is still being written. */
  progress?: string
  feedback?: string
}

type Conversation = {
  id: string
  title: string
  group: "today" | "week" | "earlier"
  turns: Turn[]
}

const groups = [
  { id: "today", label: "Today" },
  { id: "week", label: "Previous 7 days" },
  { id: "earlier", label: "Earlier" },
] as const

const suggestions = [
  "Summarize this week's customer interviews",
  "Find feature requests from enterprise accounts",
  "Compare our onboarding with Northwind's",
  "Draft a survey about notification settings",
]

const ratings = [
  { value: "helpful", label: "Helpful", icon: ThumbsUpIcon },
  { value: "not-helpful", label: "Not helpful", icon: ThumbsDownIcon },
]

const inviteQuery = `select
  date_trunc('week', w.created_at) as signup_week,
  count(*) as trials,
  avg((exists (
    select 1 from workspace_invites i
    where i.workspace_id = w.id
      and i.sent_at < w.created_at + interval '7 days'
  ))::int) as invite_rate
from workspaces w
where w.plan = 'trial'
group by 1
order by 1 desc;`

const initialConversations: Conversation[] = [
  {
    id: "activation",
    title: "Trial activation benchmarks",
    group: "today",
    turns: [
      {
        question:
          "What share of B2B trials invite a teammate in the first week, and how should we measure it for our workspaces?",
        meta: "Searched 12 sources",
        answer: [
          "Across B2B collaboration tools, 30 to 40% of trial workspaces invite a teammate in their first week, and those trials convert to paid at about three times the rate of solo ones [1]. Meridian sits at the low end: 31% of September trials sent an invite within seven days [3].",
          "Timing explains most of the gap. Teams invite people once they have something worth showing, not at signup [2]. Three changes tend to move the rate:",
          {
            list: [
              "**Ask after the first win.** Prompt for invites when someone shares their first document, not on the welcome screen [2].",
              "**Invite to something specific.** Links that open a shared document are accepted far more often than workspace invites [1].",
              "**Count sent invites, not accepted ones.** Your dashboard counts acceptances, which lag by days and hide intent [4].",
            ],
          },
          "To track it weekly, this query returns the share of trial workspaces that sent an invite within seven days of signup:",
          { code: inviteQuery, language: "sql" },
          "Add `i.accepted_at is not null` to the subquery for the stricter version your Q3 review used [3].",
        ],
        sources: [
          {
            title: "B2B product benchmarks 2026: activation and retention",
            site: "haldenresearch.com",
            kind: "web",
          },
          {
            title: "Why team trials stall before the first invite",
            site: "tessellate.blog",
            kind: "web",
          },
          {
            title: "Q3 activation review",
            site: "Meridian docs",
            kind: "workspace",
          },
          {
            title: "workspace_invites",
            site: "Meridian data catalog",
            kind: "workspace",
          },
        ],
      },
      {
        question:
          "How do Northwind and Halden ask for invites during onboarding? Compare their flows step by step.",
        meta: "Deep research",
        answer: [],
        progress: "Reading 9 of 14 sources",
      },
    ],
  },
  {
    id: "support",
    title: "Support themes from last week",
    group: "today",
    turns: [
      {
        question:
          "Group last week's support tickets into themes and rank them by volume.",
        meta: "Searched 412 tickets",
        answer: [
          "There were 412 tickets last week. Five themes cover 78% of them:",
          {
            list: [
              "**Billing and invoices**, 112 tickets, mostly failed card retries.",
              "**SSO setup**, 74 tickets, up from 41 the week before.",
              "**Slow search** in large workspaces, 58 tickets.",
              "**CSV exports** missing custom fields, 37 tickets.",
              "**Notification volume**, 33 tickets.",
            ],
          },
        ],
      },
    ],
  },
  {
    id: "pricing",
    title: "Pricing page copy review",
    group: "week",
    turns: [
      {
        question:
          "Review the copy on our new pricing page. What would confuse a first-time visitor?",
        meta: "Read the pricing page draft",
        answer: [
          "Three things stand out. The Team plan lists “seats” while the comparison table says “members”, so it's unclear whether guests count. The annual discount only appears inside the billing toggle. And the Enterprise card has no price or range, which reads as expensive rather than flexible.",
          "I'd pick one word for people, show the discount next to each price, and give Enterprise a starting price.",
        ],
      },
    ],
  },
  {
    id: "churn",
    title: "Churn reasons in Q3 exit surveys",
    group: "week",
    turns: [
      {
        question:
          "What are the top reasons people gave for cancelling in the Q3 exit survey?",
        meta: "Searched 286 responses",
        answer: [
          "Of 286 responses, the most common reasons were missing integrations (31%), price for small teams (24%) and moving to a tool the company already pays for (19%). In free-text answers, the Jira sync comes up most often, in 41 responses.",
        ],
      },
    ],
  },
  {
    id: "checkout",
    title: "Usability test plan for checkout",
    group: "week",
    turns: [
      {
        question: "Draft a usability test plan for the new checkout flow.",
        meta: "Used 2 workspace docs",
        answer: [
          "Here's a lean plan for five moderated sessions of 30 minutes each:",
          {
            list: [
              "**Goal:** find where people hesitate between choosing a plan and paying.",
              "**Participants:** admins at companies of 10 to 50 people who bought software in the last year.",
              "**Tasks:** upgrade to Team, add three seats, apply a tax ID.",
              "**Measures:** task success, time on the payment step, and errors.",
            ],
          },
        ],
      },
    ],
  },
  {
    id: "offsite",
    title:
      "Roadmap themes for the Q4 planning offsite with design and engineering leads",
    group: "earlier",
    turns: [
      {
        question:
          "Pull together roadmap themes for the Q4 planning offsite from this quarter's research.",
        meta: "Searched 38 sources",
        answer: [
          "Four themes come up across interviews, tickets and usage data: faster onboarding for teams, admin controls for larger accounts, search across workspaces, and fewer but better notifications.",
          "Admin controls have the strongest revenue signal. They come up in 9 of the last 12 lost enterprise deals.",
        ],
      },
    ],
  },
  {
    id: "security",
    title: "SOC 2 questions from Northwind's security review",
    group: "earlier",
    turns: [
      {
        question:
          "Which questions in Northwind's security questionnaire are still unanswered?",
        meta: "Read the questionnaire",
        answer: [
          "Seven of 112 are open. Four are about data residency for EU customers, two ask for the latest penetration test summary, and one asks how long audit logs are kept. All seven sit with the security team, and the questionnaire is due on November 6.",
        ],
      },
    ],
  },
]

/** The reply to new questions: the example doesn't pretend to answer them. */
const previewAnswer = [
  "This is a preview, so Kestrel doesn't write answers here. In your workspace, the answer would appear in this space, with numbered sources you can check.",
]

/** The answer as plain text, for the clipboard. */
function answerText({ answer, sources = [] }: Turn) {
  const blocks = answer.map((block) => {
    if (typeof block === "string") return block
    if ("code" in block) return block.code
    return block.list.map((item) => `- ${item}`).join("\n")
  })
  const cited = sources.map(
    (source, i) => `[${i + 1}] ${source.title}, ${source.site}`
  )
  return [...blocks, ...cited].join("\n\n").replace(/\*\*|`/g, "")
}

function Mark({ className }: { className?: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "flex size-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-background [&_svg]:size-4",
        className
      )}
    >
      <TelescopeIcon />
    </span>
  )
}

/** Names an icon-only control: read by screen readers, shown as a tooltip. */
function Hint({
  label,
  children,
}: {
  label: string
  children: React.ReactElement
}) {
  return (
    <Tooltip>
      <TooltipTrigger aria-label={label} render={children} />
      <TooltipContent>{label}</TooltipContent>
    </Tooltip>
  )
}

function IconButton({
  label,
  className,
  ...props
}: ButtonProps & { label: string }) {
  return (
    <Hint label={label}>
      <Button
        variant="ghost"
        size="icon-sm"
        className={cn("text-muted-foreground hover:text-foreground", className)}
        {...props}
      />
    </Hint>
  )
}

function CopyButton({ text, label }: { text: string; label: string }) {
  const [copied, setCopied] = React.useState(false)

  // The check mark confirms the copy for a moment, then turns back.
  React.useEffect(() => {
    if (!copied) return
    const timer = setTimeout(() => setCopied(false), 1600)
    return () => clearTimeout(timer)
  }, [copied])

  async function copy() {
    try {
      await navigator.clipboard.writeText(text)
      setCopied(true)
    } catch {
      // No clipboard access (an insecure page, a denied permission): nothing
      // was copied, so nothing is confirmed.
    }
  }

  return (
    <>
      <IconButton label={label} onClick={copy}>
        {copied ? <CheckIcon /> : <CopyIcon />}
      </IconButton>
      <span role="status" className="sr-only">
        {copied ? "Copied to clipboard" : ""}
      </span>
    </>
  )
}

/** An attached file, on a question or in the composer before it's sent. */
function FileChip({
  name,
  children,
}: {
  name: string
  children?: React.ReactNode
}) {
  return (
    <span className="inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border bg-card px-2 text-xs has-[button]:pr-0.5">
      <FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
      <span className="truncate">{name}</span>
      {children}
    </span>
  )
}

type Cite = (n: number) => React.ReactNode

/** Renders **bold**, `code` and [n] citations inside a line of an answer. */
function Inline({ text, cite }: { text: string; cite: Cite }) {
  return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\[\d+\])/).map((part, i) => {
    if (part.startsWith("**")) {
      return (
        <strong key={i} className="font-medium">
          {part.slice(2, -2)}
        </strong>
      )
    }
    if (part.startsWith("`")) {
      return (
        <code
          key={i}
          className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.875em]"
        >
          {part.slice(1, -1)}
        </code>
      )
    }
    if (/^\[\d+\]$/.test(part)) {
      return (
        <React.Fragment key={i}>
          {cite(Number(part.slice(1, -1)))}
        </React.Fragment>
      )
    }
    return part
  })
}

function AnswerBlock({ block, cite }: { block: Block; cite: Cite }) {
  if (typeof block === "string") {
    return (
      <p>
        <Inline text={block} cite={cite} />
      </p>
    )
  }
  if ("code" in block) {
    return (
      <figure className="overflow-hidden rounded-xl border bg-surface">
        <figcaption className="flex h-10 items-center justify-between border-b pr-1.5 pl-4">
          <span className="font-mono text-xs text-muted-foreground uppercase">
            {block.language}
          </span>
          <CopyButton text={block.code} label="Copy code" />
        </figcaption>
        <pre className="scrollbar-thin overflow-x-auto p-4 font-mono text-body-xs">
          <code>{block.code}</code>
        </pre>
      </figure>
    )
  }
  return (
    <ul className="flex list-disc flex-col gap-2 pl-5 marker:text-muted-foreground">
      {block.list.map((item) => (
        <li key={item} className="pl-1">
          <Inline text={item} cite={cite} />
        </li>
      ))}
    </ul>
  )
}

function Thinking({ progress }: { progress: string }) {
  return (
    <div className="flex flex-col gap-4">
      <p
        role="status"
        className="flex items-center gap-2 text-sm text-muted-foreground"
      >
        <Spinner aria-hidden="true" className="size-3.5" />
        {progress}
      </p>
      <div className="flex flex-col gap-3">
        <Skeleton className="h-3.5 w-full" />
        <Skeleton className="h-3.5 w-11/12" />
        <Skeleton className="h-3.5 w-3/5" />
      </div>
    </div>
  )
}

function Sources({
  sources,
  sourceId,
  cited,
}: {
  sources: Source[]
  sourceId: (n: number) => string
  /** The source whose citation is hovered or focused, highlighted here. */
  cited?: number
}) {
  return (
    <div className="flex flex-col gap-2 pt-1">
      <p className="text-label-xs text-muted-foreground">Sources</p>
      <ol className="grid gap-2 @lg:grid-cols-2">
        {sources.map((source, i) => {
          const Icon = source.kind === "web" ? GlobeIcon : FileTextIcon
          return (
            <li key={source.title} className="min-w-0">
              <Item
                id={sourceId(i + 1)}
                variant="outline"
                size="sm"
                render={<a href="#" />}
                data-cited={cited === i + 1 || undefined}
                className="flex-nowrap rounded-lg data-cited:border-border-strong data-cited:bg-accent"
              >
                <ItemMedia>
                  <Badge variant="secondary" className="size-5 px-0 numeric">
                    {i + 1}
                  </Badge>
                </ItemMedia>
                <ItemContent className="min-w-0 gap-0.5">
                  <ItemTitle className="w-full">
                    <span className="truncate">{source.title}</span>
                  </ItemTitle>
                  <ItemDescription className="flex items-center gap-1.5">
                    <Icon className="size-3 shrink-0" aria-hidden="true" />
                    <span className="truncate">{source.site}</span>
                  </ItemDescription>
                </ItemContent>
              </Item>
            </li>
          )
        })}
      </ol>
    </div>
  )
}

function AnswerActions({
  turn,
  onRegenerate,
  onFeedback,
}: {
  turn: Turn
  onRegenerate: () => void
  onFeedback: (feedback: string | undefined) => void
}) {
  return (
    <div className="-ml-1.5 flex items-center gap-0.5">
      <CopyButton text={answerText(turn)} label="Copy answer" />
      <IconButton label="Regenerate" onClick={onRegenerate}>
        <RefreshCwIcon />
      </IconButton>
      <ToggleGroup
        value={turn.feedback ? [turn.feedback] : []}
        onValueChange={(value) => onFeedback(value[0])}
        aria-label="Rate this answer"
        spacing={0.5}
      >
        {ratings.map(({ value, label, icon: Icon }) => (
          <Hint key={value} label={label}>
            <ToggleGroupItem value={value} size="sm" className="px-0">
              <Icon />
            </ToggleGroupItem>
          </Hint>
        ))}
      </ToggleGroup>
      {/* Announced at every width; shown once there's room beside the buttons. */}
      <span
        role="status"
        className="sr-only text-caption text-muted-foreground @xs:not-sr-only @xs:ml-2"
      >
        {turn.feedback ? "Thanks for the feedback" : ""}
      </span>
    </div>
  )
}

function Answer({
  turn,
  onRegenerate,
  onFeedback,
}: React.ComponentProps<typeof AnswerActions>) {
  const id = React.useId()
  const [cited, setCited] = React.useState<number>()
  const sources = turn.sources ?? []
  const sourceId = (n: number) => `${id}-source-${n}`

  // Hovering or focusing a citation names its source and highlights it in
  // the list; clicking moves focus there.
  const cite = (n: number) => {
    const source = sources[n - 1]
    if (!source) return null
    return (
      <Tooltip onOpenChange={(open) => setCited(open ? n : undefined)}>
        <TooltipTrigger
          render={
            <Badge
              variant="secondary"
              size="sm"
              render={<button type="button" />}
              aria-label={`Source ${n}: ${source.title}`}
              onClick={() => document.getElementById(sourceId(n))?.focus()}
              className="min-w-[1.125rem] -translate-y-px px-1 align-middle numeric hover:bg-secondary-hover hover:text-foreground"
            />
          }
        >
          {n}
        </TooltipTrigger>
        <TooltipContent>{source.site}</TooltipContent>
      </Tooltip>
    )
  }

  return (
    <article className="flex flex-col gap-4">
      <header className="flex h-7 items-center gap-2.5">
        <Mark className="size-6 rounded-md [&_svg]:size-3.5" />
        <span className="text-label-sm">Kestrel</span>
        <span className="truncate pr-0.5 text-sm text-muted-foreground">
          {turn.meta}
        </span>
      </header>
      {turn.progress ? (
        <Thinking progress={turn.progress} />
      ) : (
        <>
          <div className="flex flex-col gap-4 text-body-md text-pretty">
            {turn.answer.map((block, i) => (
              <AnswerBlock key={i} block={block} cite={cite} />
            ))}
          </div>
          {sources.length > 0 && (
            <Sources sources={sources} sourceId={sourceId} cited={cited} />
          )}
          <AnswerActions
            turn={turn}
            onRegenerate={onRegenerate}
            onFeedback={onFeedback}
          />
        </>
      )}
    </article>
  )
}

function Question({ turn }: { turn: Turn }) {
  return (
    <div className="flex flex-col items-end gap-2">
      {!!turn.files?.length && (
        <ul className="flex flex-wrap justify-end gap-1.5">
          {turn.files.map((name, i) => (
            <li key={i} className="max-w-56">
              <FileChip name={name} />
            </li>
          ))}
        </ul>
      )}
      <p className="max-w-[85%] rounded-xl bg-muted px-4 py-2.5 text-body-md break-words whitespace-pre-wrap">
        <span className="sr-only">You: </span>
        {turn.question}
      </p>
    </div>
  )
}

function History({
  conversations,
  activeId,
  onSelect,
  onNewChat,
}: {
  conversations: Conversation[]
  activeId: string
  onSelect: (id: string) => void
  onNewChat: () => void
}) {
  const [query, setQuery] = React.useState("")
  const matches = conversations.filter((conversation) =>
    conversation.title.toLowerCase().includes(query.trim().toLowerCase())
  )
  const sections = groups
    .map((group) => ({
      ...group,
      items: matches.filter((item) => item.group === group.id),
    }))
    .filter((section) => section.items.length > 0)

  return (
    <div className="flex min-h-0 flex-1 flex-col">
      <div className="flex flex-col gap-2 p-3">
        <Button variant="outline" size="sm" onClick={onNewChat}>
          <SquarePenIcon data-icon="inline-start" />
          New chat
        </Button>
        <InputGroup className="h-8">
          <InputGroupAddon>
            <SearchIcon />
          </InputGroupAddon>
          <InputGroupInput
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            placeholder="Search chats"
            aria-label="Search chats"
          />
        </InputGroup>
      </div>
      <nav
        aria-label="Chats"
        className="relative flex min-h-0 flex-1 scrollbar-thin flex-col gap-4 overflow-y-auto px-2 pt-1 pb-3"
      >
        {sections.map((section) => (
          <div key={section.id} className="flex flex-col gap-1">
            <h3 className="px-2 text-label-xs text-muted-foreground">
              {section.label}
            </h3>
            <ul className="flex flex-col gap-px">
              {section.items.map((conversation) => (
                <li key={conversation.id}>
                  <button
                    type="button"
                    aria-current={
                      conversation.id === activeId ? "page" : undefined
                    }
                    onClick={() => onSelect(conversation.id)}
                    className="flex h-8 w-full items-center rounded-lg px-2 text-left text-sm text-muted-foreground focus-ring-inset outline-none hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
                  >
                    <span className="truncate">{conversation.title}</span>
                  </button>
                </li>
              ))}
            </ul>
          </div>
        ))}
        {sections.length === 0 && (
          <p className="px-2 py-6 text-center text-sm text-balance text-muted-foreground">
            {`No chats match “${query.trim()}”`}
          </p>
        )}
      </nav>
    </div>
  )
}

/** The workspace, its chats and who is signed in. Shown on wide containers. */
function Sidebar({ children }: { children: React.ReactNode }) {
  return (
    <aside className="hidden w-64 shrink-0 flex-col border-r bg-surface @4xl/app:flex @6xl/app:w-72">
      <h2 className="sr-only">Chat history</h2>
      <div className="flex h-14 shrink-0 items-center gap-2.5 border-b px-4">
        <Mark />
        <div className="flex min-w-0 flex-col">
          <span className="text-label-sm">Kestrel</span>
          <span className="truncate text-xs text-muted-foreground">
            Meridian product team
          </span>
        </div>
      </div>
      {children}
      <div className="flex items-center gap-2.5 border-t px-4 py-3">
        <Avatar size="sm">
          <AvatarFallback>PR</AvatarFallback>
        </Avatar>
        <div className="flex min-w-0 flex-col">
          <span className="truncate text-sm font-medium">Priya Raman</span>
          <span className="truncate text-xs text-muted-foreground">
            Product lead
          </span>
        </div>
      </div>
    </aside>
  )
}

function EmptyChat({ onPick }: { onPick: (prompt: string) => void }) {
  return (
    <div className="flex flex-1 flex-col justify-center gap-6 py-6">
      <div className="flex flex-col gap-1.5">
        <h3 className="text-heading-md">What should we look into?</h3>
        <p className="text-sm text-muted-foreground">
          Kestrel searches your workspace and the web, and cites every source it
          uses.
        </p>
      </div>
      <ul
        aria-label="Suggestions"
        className="flex flex-col divide-y overflow-hidden rounded-xl border"
      >
        {suggestions.map((prompt) => (
          <li key={prompt}>
            <button
              type="button"
              onClick={() => onPick(prompt)}
              className="w-full px-4 py-3 text-left text-sm focus-ring-inset transition-colors duration-fast outline-none hover:bg-accent"
            >
              {prompt}
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

function Composer({
  ref,
  placeholder,
  onSend,
}: {
  ref: React.Ref<HTMLTextAreaElement>
  placeholder: string
  onSend: (question: string, files: string[]) => void
}) {
  const [draft, setDraft] = React.useState("")
  const [files, setFiles] = React.useState<File[]>([])
  const fileInputRef = React.useRef<HTMLInputElement>(null)
  const empty = !draft.trim()

  function send() {
    if (empty) return
    onSend(
      draft.trim(),
      files.map((file) => file.name)
    )
    setDraft("")
    setFiles([])
  }

  function attach(event: React.ChangeEvent<HTMLInputElement>) {
    const picked = Array.from(event.target.files ?? [])
    setFiles((current) => [...current, ...picked])
    event.target.value = ""
  }

  return (
    <div className="mx-auto w-full max-w-2xl px-4 pb-3 @lg:px-6">
      <form
        onSubmit={(event) => {
          event.preventDefault()
          send()
        }}
      >
        <InputGroup className="rounded-xl">
          {files.length > 0 && (
            <InputGroupAddon align="block-start" className="flex-wrap gap-1.5">
              {/* Keyed by position: the same file can be attached twice, and
                  a chip keeps no state that a shifted key could mix up. */}
              {files.map((file, i) => (
                <FileChip key={i} name={file.name}>
                  <InputGroupButton
                    size="icon-xs"
                    aria-label={`Remove ${file.name}`}
                    onClick={() =>
                      setFiles((current) => current.filter((f) => f !== file))
                    }
                  >
                    <XIcon />
                  </InputGroupButton>
                </FileChip>
              ))}
            </InputGroupAddon>
          )}
          <InputGroupTextarea
            ref={ref}
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              // Enter sends, Shift+Enter starts a new line, and an Enter that
              // confirms an IME composition (Japanese, Chinese…) does neither.
              if (
                event.key === "Enter" &&
                !event.shiftKey &&
                !event.nativeEvent.isComposing
              ) {
                event.preventDefault()
                send()
              }
            }}
            placeholder={placeholder}
            aria-label="Message Kestrel"
            className="max-h-40 min-h-12"
          />
          <InputGroupAddon align="block-end" className="gap-1">
            <Hint label="Attach files">
              <InputGroupButton
                size="icon-sm"
                onClick={() => fileInputRef.current?.click()}
              >
                <PaperclipIcon />
              </InputGroupButton>
            </Hint>
            <InputGroupText className="ml-auto hidden text-xs @md:flex">
              Enter to send · Shift + Enter for a new line
            </InputGroupText>
            {/* Focusable while disabled: a natively disabled button would grey
                out the whole group, whose styles react to any disabled child. */}
            <InputGroupButton
              type="submit"
              variant="default"
              size="icon-sm"
              aria-label="Send message"
              disabled={empty}
              focusableWhenDisabled
              className="ml-auto @md:ml-2"
            >
              <ArrowUpIcon />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
        <input
          ref={fileInputRef}
          type="file"
          multiple
          hidden
          onChange={attach}
        />
      </form>
      <p className="mt-2 text-center text-caption text-balance text-muted-foreground">
        Answers can be wrong. Check important information.
      </p>
    </div>
  )
}

export default function AssistantExample() {
  const [conversations, setConversations] = React.useState(initialConversations)
  const [activeId, setActiveId] = React.useState("activation")
  const [historyOpen, setHistoryOpen] = React.useState(false)
  const scrollRef = React.useRef<HTMLDivElement>(null)
  const composerRef = React.useRef<HTMLTextAreaElement>(null)
  const followNextChange = React.useRef(false)

  const active =
    conversations.find((conversation) => conversation.id === activeId) ??
    conversations[0]

  // Scroll down to a question once it's sent, and to its answer once it
  // arrives, but not when someone opens another chat or rates an answer.
  React.useEffect(() => {
    const scroller = scrollRef.current
    if (!scroller || !followNextChange.current) return
    followNextChange.current = false
    scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" })
  }, [active.turns])

  function update(
    id: string,
    change: (conversation: Conversation) => Partial<Conversation>
  ) {
    setConversations((current) =>
      current.map((conversation) =>
        conversation.id === id
          ? { ...conversation, ...change(conversation) }
          : conversation
      )
    )
  }

  // Turns are only ever added at the end, so an index names one for good.
  function updateTurn(id: string, index: number, change: Partial<Turn>) {
    update(id, ({ turns }) => ({
      turns: turns.map((turn, i) =>
        i === index ? { ...turn, ...change } : turn
      ),
    }))
  }

  function select(id: string) {
    setActiveId(id)
    setHistoryOpen(false)
    scrollRef.current?.scrollTo({ top: 0 })
  }

  function startChat() {
    // Reuse an untouched new chat instead of stacking empty ones.
    const blank = conversations.find(
      (conversation) => !conversation.turns.length
    )
    const id = blank?.id ?? `chat-${conversations.length}`
    if (!blank) {
      setConversations((current) => [
        { id, title: "New chat", group: "today", turns: [] },
        ...current,
      ])
    }
    setActiveId(id)
    setHistoryOpen(false)
    composerRef.current?.focus()
  }

  function send(question: string, files: string[] = []) {
    const { id } = active
    const index = active.turns.length
    // Appends to the turns of the moment, not to the ones this render saw.
    update(id, ({ title, turns }) => ({
      title: turns.length ? title : question,
      turns: [
        ...turns,
        {
          question,
          files,
          meta: "Preview",
          answer: [],
          progress: "Searching your workspace",
        },
      ],
    }))
    followNextChange.current = true
    setTimeout(() => {
      followNextChange.current = true
      updateTurn(id, index, {
        answer: previewAnswer,
        progress: undefined,
      })
    }, 1800)
  }

  function regenerate(index: number) {
    const { id } = active
    updateTurn(id, index, { progress: "Rewriting the answer" })
    setTimeout(() => updateTurn(id, index, { progress: undefined }), 1600)
  }

  const history = (
    <History
      conversations={conversations}
      activeId={active.id}
      onSelect={select}
      onNewChat={startChat}
    />
  )

  return (
    <TooltipProvider>
      <div className="@container/app flex h-[clamp(32rem,calc(100dvh-4rem),44rem)] overflow-hidden rounded-xl border bg-background">
        <Sidebar>{history}</Sidebar>

        <section className="@container flex min-w-0 flex-1 flex-col">
          <header className="flex h-14 shrink-0 items-center gap-1 border-b px-3 @lg:px-4">
            {/* Without room for the sidebar, the history opens in a sheet. */}
            <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
              <SheetTrigger
                render={
                  <IconButton
                    label="Open chat history"
                    className="@4xl/app:hidden"
                  />
                }
              >
                <PanelLeftIcon />
              </SheetTrigger>
              <SheetContent
                side="left"
                className="w-[min(18rem,calc(100%-3rem))]"
              >
                <SheetHeader className="px-4 pt-4 pb-1">
                  <SheetTitle className="text-heading-sm">
                    Chat history
                  </SheetTitle>
                </SheetHeader>
                {history}
              </SheetContent>
            </Sheet>
            <h2 className="min-w-0 flex-1 truncate px-1 text-heading-xs">
              {active.title}
            </h2>
            <IconButton
              label="New chat"
              onClick={startChat}
              className="@4xl/app:hidden"
            >
              <SquarePenIcon />
            </IconButton>
          </header>

          <div
            ref={scrollRef}
            className="relative min-h-0 flex-1 scrollbar-thin overflow-y-auto"
          >
            <div
              role="log"
              aria-label="Messages"
              className="mx-auto flex min-h-full w-full max-w-2xl flex-col gap-10 px-4 py-6 @lg:px-6"
            >
              {active.turns.length === 0 && (
                <EmptyChat
                  onPick={(prompt) => {
                    send(prompt)
                    composerRef.current?.focus()
                  }}
                />
              )}
              {active.turns.map((turn, i) => (
                <React.Fragment key={`${active.id}-${i}`}>
                  <Question turn={turn} />
                  <Answer
                    turn={turn}
                    onRegenerate={() => regenerate(i)}
                    onFeedback={(feedback) =>
                      updateTurn(active.id, i, { feedback })
                    }
                  />
                </React.Fragment>
              ))}
            </div>
          </div>

          <Composer
            ref={composerRef}
            onSend={send}
            placeholder={
              active.turns.length
                ? "Ask a follow-up…"
                : "Ask about your product, customers or market…"
            }
          />
        </section>
      </div>
    </TooltipProvider>
  )
}
app/assistant/page.tsx
import type { Metadata } from "next"

import Assistant from "@/components/assistant/assistant"

export const metadata: Metadata = {
  title: "Assistant",
  description: "A workspace for an AI assistant: conversation history, answers with sources and code, and a composer.",
}

export default function AssistantPage() {
  return (
    <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
      <Assistant />
    </main>
  )
}