Skip to content
UI Kit
Accent color

AI chat component for React

Chats, prompts, agents and answers with sources. Replies stream in, everything works with the keyboard.

One of 3 components in AI and chat

6 variants

The conversation, from window to sources

An AI chat is a conversation that has to stay readable while answers stream in. This component renders messages in a log that screen readers follow, keeps the scroll position steady while text arrives, and lets people stop a running answer and copy or retry any reply.

Chat window is the complete chat with header, messages and input. Prompt composer is the input on its own for a page that starts with a question. Agent steps shows what an assistant does while it works, step by step. With chat history adds a list of past conversations, Answer with sources puts numbered citations next to the claims, and Voice widget is the floating button for spoken questions.

Mark answers as AI generated and show where they come from. Keep the input reachable at all times, also on phones with the keyboard open. The component draws the interface, you connect any model or API through its props, from a hosted service to your own server.

Variants

Chat window

A complete conversation with streaming, message actions and quick replies.

Prompt composer

A large composer with model, web search, attachments and suggestion cards.

Agent steps

A task in steps with tools, live progress, details to open and an approval before anything is sent.

With chat history

Chats grouped by day with search and a new chat button. Phones open the list as a sheet.

Answer with sources

Sources first, then an answer whose citations light up their source. Related questions follow.

Voice widget

A compact assistant in the corner with a microphone, a live waveform, quick replies and text as fallback.

Details

What AI chat brings along, read from the registry and the source.

Category
AI and chat
Area
App
Variants
6
Version
1.0.1 · since 1.0.0
File
components/ui/ai-chat.tsx
Brings along
utilskitbuttonicon
npm packages
None
Languages
English and German, 55 texts of its own
Studio
Adjust in code
In collections
AI product

Props

The AiChatProps interface from components/ui/ai-chat.tsx, with the comments from the code.

  • variantoptional
    AiChatVariant

    chat-windowprompt-composeragent-stepshistory-sidebaranswer-with-sourcesvoice-widget

  • assistantrequired
    { name: string; model?: string; avatar?: React.ReactNode }
  • useroptional
    { name: string }
  • messagesoptional
    ChatMessage[]

    Conversation so far

  • suggestionsoptional
    { label: string; prompt?: string; icon?: IconName }[]

    Prompts to start with

  • placeholderoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • modelsoptional
    string[]

    Models to pick from in the composer

  • respondoptional
    (prompt: string) => string | Promise<string>

    Produces the reply for a prompt. Without it the block answers with a short built-in text, so the demo streams like the real thing.

  • taskoptional
    { title: string; started: string; files?: string[] }

    agent-steps

  • stepsoptional
    AgentStep[]
  • historyoptional
    { id: string; title: string; group: string; active?: boolean }[]

    history-sidebar

  • questionoptional
    string

    answer-with-sources

  • answeroptional
    { text: string; cites?: number[] }[]
  • sourcesoptional
    Source[]
  • relatedoptional
    string[]
  • defaultOpenoptional
    boolean

    voice-widget: open on load

Built-in texts

AI chat writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 55 texts
messageEnglish MessageGerman Nachricht
messageToEnglish Message {name}German Nachricht an {name}
sendEnglish SendGerman Senden
stopEnglish StopGerman Stoppen
attachEnglish Attach filesGerman Dateien anhängen
removeEnglish Remove {name}German {name} entfernen
modelEnglish ModelGerman Modell
onlineEnglish OnlineGerman Online
thinkingEnglish ThinkingGerman Denkt nach
copyEnglish CopyGerman Kopieren
copiedEnglish CopiedGerman Kopiert
goodEnglish Good answerGerman Gute Antwort
badEnglish Bad answerGerman Schlechte Antwort
retryEnglish Try againGerman Neu versuchen
enterHintEnglish Enter to send, Shift Enter for a new lineGerman Enter sendet, Umschalt Enter macht eine neue Zeile
disclaimerEnglish Answers can be wrong. Check what matters.German Antworten können falsch sein. Prüfe, was wichtig ist.
webSearchEnglish Search the webGerman Im Web suchen
fallbackEnglish Here is a first draft to work with: - I summarised the key points in three short paragraphs - I kept your tone and the numbers you gave me - Tell me what to cut or expand Want me to turn this into an email as well?German Hier ist ein erster Entwurf zum Weiterarbeiten: - Die wichtigsten Punkte stehen in drei kurzen Absätzen - Dein Ton und deine Zahlen sind geblieben - Sag mir, was raus soll oder mehr Platz braucht Soll ich daraus auch eine E-Mail machen?
youEnglish YouGerman Du
newChatEnglish New chatGerman Neuer Chat
searchChatsEnglish Search chatsGerman Chats durchsuchen
historyEnglish ChatsGerman Chats
openHistoryEnglish Show chatsGerman Chats zeigen
closeHistoryEnglish Close chatsGerman Chats schließen
noChatsEnglish No chat matches.German Kein Chat passt.
progressEnglish {done} of {total} stepsGerman {done} von {total} Schritten
runningEnglish WorkingGerman Arbeitet
doneEnglish DoneGerman Fertig
pendingEnglish WaitingGerman Wartet
approvalEnglish Needs your approvalGerman Braucht deine Freigabe
failedEnglish FailedGerman Fehlgeschlagen
approveEnglish Approve and continueGerman Freigeben und weiter
declineEnglish Skip this stepGerman Schritt überspringen
showDetailsEnglish Show detailsGerman Details zeigen
hideDetailsEnglish Hide detailsGerman Details verbergen
stopAgentEnglish Stop agentGerman Agent stoppen
stoppedEnglish Stopped. Nothing after this step was changed.German Gestoppt. Nach diesem Schritt wurde nichts verändert.
finishedEnglish Finished. Everything is ready for review.German Fertig. Alles liegt zur Durchsicht bereit.
filesEnglish FilesGerman Dateien
sourcesEnglish SourcesGerman Quellen
answerEnglish AnswerGerman Antwort
relatedEnglish RelatedGerman Weiterführend
followUpEnglish Ask a follow-upGerman Nachfrage stellen
sourceEnglish Source {n}German Quelle {n}
openAssistantEnglish Open assistantGerman Assistent öffnen
closeAssistantEnglish Close assistantGerman Assistent schließen
listenEnglish Start speakingGerman Sprechen
stopListeningEnglish Stop listeningGerman Zuhören beenden
listeningEnglish Listening…German Hört zu…
tapToTalkEnglish Tap the microphone and talk, or type below.German Tippe aufs Mikrofon und sprich, oder schreib unten.
typeInsteadEnglish Type a messageGerman Nachricht schreiben
heardEnglish Do you have a table for four on Friday evening?German Habt ihr am Freitagabend einen Tisch für vier?
voiceReplyEnglish Yes, Friday at 7:30 pm is free for four people. Shall I book it under your name?German Ja, Freitag um 19:30 Uhr ist für vier Personen frei. Soll ich auf deinen Namen reservieren?
nudgeEnglish Questions? Ask by voice or text.German Fragen? Frag per Stimme oder Text.
dismissEnglish DismissGerman Ausblenden

Accessibility

Read from the source of the component.

  • AI chat handles the keys Escape, Enter and Space itself.
  • Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
  • Sets aria-controls, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-relevant, aria-valuemax, aria-valuemin and aria-valuenow, so screen readers announce purpose and state.
  • Uses the roles dialog, log, progressbar and status.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 14 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 1,474 lines
components/ui/ai-chat.tsx
'use client';

import { Fragment, useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useSpotlight, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';

export type AiChatVariant = 'chat-window' | 'prompt-composer' | 'agent-steps' | 'history-sidebar' | 'answer-with-sources' | 'voice-widget';

export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant';
  /** Plain text. Blank lines make paragraphs, lines starting with "- " make a list, **bold** works. */
  content: string;
  time?: string;
}

export interface AgentStep {
  title: string;
  detail?: string;
  /** The tool the agent used, e.g. "Read spreadsheet" */
  tool?: string;
  status: 'done' | 'running' | 'pending' | 'approval' | 'error';
  duration?: string;
  /** Lines shown when the step is opened */
  output?: string[];
}

export interface Source {
  id: number;
  title: string;
  site: string;
  snippet: string;
  href: string;
}

export interface AiChatProps {
  variant?: AiChatVariant;
  assistant: { name: string; model?: string; avatar?: React.ReactNode };
  user?: { name: string };
  /** Conversation so far */
  messages?: ChatMessage[];
  /** Prompts to start with */
  suggestions?: { label: string; prompt?: string; icon?: IconName }[];
  placeholder?: string;
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Models to pick from in the composer */
  models?: string[];
  /**
   * Produces the reply for a prompt. Without it the block answers with a short
   * built-in text, so the demo streams like the real thing.
   */
  respond?: (prompt: string) => string | Promise<string>;
  /** agent-steps */
  task?: { title: string; started: string; files?: string[] };
  steps?: AgentStep[];
  /** history-sidebar */
  history?: { id: string; title: string; group: string; active?: boolean }[];
  /** answer-with-sources */
  question?: string;
  answer?: { text: string; cites?: number[] }[];
  sources?: Source[];
  related?: string[];
  /** voice-widget: open on load */
  defaultOpen?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: {
    message: 'Message',
    messageTo: 'Message {name}',
    send: 'Send',
    stop: 'Stop',
    attach: 'Attach files',
    remove: 'Remove {name}',
    model: 'Model',
    online: 'Online',
    thinking: 'Thinking',
    copy: 'Copy',
    copied: 'Copied',
    good: 'Good answer',
    bad: 'Bad answer',
    retry: 'Try again',
    enterHint: 'Enter to send, Shift Enter for a new line',
    disclaimer: 'Answers can be wrong. Check what matters.',
    webSearch: 'Search the web',
    fallback: 'Here is a first draft to work with:\n\n- I summarised the key points in three short paragraphs\n- I kept your tone and the numbers you gave me\n- Tell me what to cut or expand\n\nWant me to turn this into an email as well?',
    you: 'You',
    newChat: 'New chat',
    searchChats: 'Search chats',
    history: 'Chats',
    openHistory: 'Show chats',
    closeHistory: 'Close chats',
    noChats: 'No chat matches.',
    progress: '{done} of {total} steps',
    running: 'Working',
    done: 'Done',
    pending: 'Waiting',
    approval: 'Needs your approval',
    failed: 'Failed',
    approve: 'Approve and continue',
    decline: 'Skip this step',
    showDetails: 'Show details',
    hideDetails: 'Hide details',
    stopAgent: 'Stop agent',
    stopped: 'Stopped. Nothing after this step was changed.',
    finished: 'Finished. Everything is ready for review.',
    files: 'Files',
    sources: 'Sources',
    answer: 'Answer',
    related: 'Related',
    followUp: 'Ask a follow-up',
    source: 'Source {n}',
    openAssistant: 'Open assistant',
    closeAssistant: 'Close assistant',
    listen: 'Start speaking',
    stopListening: 'Stop listening',
    listening: 'Listening…',
    tapToTalk: 'Tap the microphone and talk, or type below.',
    typeInstead: 'Type a message',
    heard: 'Do you have a table for four on Friday evening?',
    voiceReply: 'Yes, Friday at 7:30 pm is free for four people. Shall I book it under your name?',
    nudge: 'Questions? Ask by voice or text.',
    dismiss: 'Dismiss',
  },
  de: {
    message: 'Nachricht',
    messageTo: 'Nachricht an {name}',
    send: 'Senden',
    stop: 'Stoppen',
    attach: 'Dateien anhängen',
    remove: '{name} entfernen',
    model: 'Modell',
    online: 'Online',
    thinking: 'Denkt nach',
    copy: 'Kopieren',
    copied: 'Kopiert',
    good: 'Gute Antwort',
    bad: 'Schlechte Antwort',
    retry: 'Neu versuchen',
    enterHint: 'Enter sendet, Umschalt Enter macht eine neue Zeile',
    disclaimer: 'Antworten können falsch sein. Prüfe, was wichtig ist.',
    webSearch: 'Im Web suchen',
    fallback: 'Hier ist ein erster Entwurf zum Weiterarbeiten:\n\n- Die wichtigsten Punkte stehen in drei kurzen Absätzen\n- Dein Ton und deine Zahlen sind geblieben\n- Sag mir, was raus soll oder mehr Platz braucht\n\nSoll ich daraus auch eine E-Mail machen?',
    you: 'Du',
    newChat: 'Neuer Chat',
    searchChats: 'Chats durchsuchen',
    history: 'Chats',
    openHistory: 'Chats zeigen',
    closeHistory: 'Chats schließen',
    noChats: 'Kein Chat passt.',
    progress: '{done} von {total} Schritten',
    running: 'Arbeitet',
    done: 'Fertig',
    pending: 'Wartet',
    approval: 'Braucht deine Freigabe',
    failed: 'Fehlgeschlagen',
    approve: 'Freigeben und weiter',
    decline: 'Schritt überspringen',
    showDetails: 'Details zeigen',
    hideDetails: 'Details verbergen',
    stopAgent: 'Agent stoppen',
    stopped: 'Gestoppt. Nach diesem Schritt wurde nichts verändert.',
    finished: 'Fertig. Alles liegt zur Durchsicht bereit.',
    files: 'Dateien',
    sources: 'Quellen',
    answer: 'Antwort',
    related: 'Weiterführend',
    followUp: 'Nachfrage stellen',
    source: 'Quelle {n}',
    openAssistant: 'Assistent öffnen',
    closeAssistant: 'Assistent schließen',
    listen: 'Sprechen',
    stopListening: 'Zuhören beenden',
    listening: 'Hört zu…',
    tapToTalk: 'Tippe aufs Mikrofon und sprich, oder schreib unten.',
    typeInstead: 'Nachricht schreiben',
    heard: 'Habt ihr am Freitagabend einen Tisch für vier?',
    voiceReply: 'Ja, Freitag um 19:30 Uhr ist für vier Personen frei. Soll ich auf deinen Namen reservieren?',
    nudge: 'Fragen? Frag per Stimme oder Text.',
    dismiss: 'Ausblenden',
  },
};

type Texts = (typeof TEXTS)['en'];
type P = AiChatProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));

/* ------------------------------------------------------------------ */
/* Motion of the app screens                                           */
/* ------------------------------------------------------------------ */
/*
 * The same small set in every app block (auth, dashboard, files, inbox …),
 * kept in the block so it ships on its own:
 *   useEnter   entrance that waits until its area scrolls in. The area gets
 *              data-kitm-scope and data-kitm={state}, children take kitm-wipe
 *              (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
 *              kitm-up and kitm-pop, with --kitm-d as delay.
 *   useGlide   a marker inside the active item ([data-glide]) glides over
 *              from where the previous one stood: tabs, segments, steps.
 *   useFlip    rows with data-flip="<id>" glide to their new place when a
 *              list is sorted or filtered, new rows fade in.
 *   kitm-in, kitm-fly, kitm-tick for things that appear after an action.
 * The server renders the end state, without JavaScript nothing waits longer
 * than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
 * clip-path change, so no layout shifts.
 */
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
  ['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
  ['grow-x', 'transform:scaleX(0)', 900],
  ['grow-y', 'transform:scaleY(0)', 800],
  ['up', 'opacity:0;transform:translateY(10px)', 600],
  ['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
  KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
  '@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
  '.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
  '@media (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
  '[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
  '}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
  KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
  '}';

function KitmStyle() {
  // React hoists it once into the head, however many app blocks render
  return (
    <style href="kitm" precedence="default">
      {KITM_CSS}
    </style>
  );
}

/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
  const ref = useRef<T>(null);
  const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
  useEffect(() => {
    const el = ref.current;
    if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
    setState('wait');
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        setState('play');
        io.disconnect();
      },
      { rootMargin: '0px 0px -8% 0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, []);
  return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}

type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
  const r = el.getBoundingClientRect();
  const b = root.getBoundingClientRect();
  return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};

/**
 * The marker ([data-glide]) is rendered inside the active item, so the server
 * and a page without JavaScript show it in place. After a change it starts
 * where the old one stood and glides into the new item.
 */
function useGlide<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Box | null>(null);
  // Read during render, while the DOM still shows the old marker
  if (typeof window !== 'undefined' && ref.current) {
    const m = ref.current.querySelector('[data-glide]');
    before.current = m ? boxOf(m, ref.current) : null;
  }
  useIso(() => {
    const root = ref.current;
    const m = root?.querySelector<HTMLElement>('[data-glide]');
    const a = before.current;
    if (!root || !m || !a || prefersReducedMotion()) return;
    const b = boxOf(m, root);
    if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
    m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
  }, [key]);
  return ref;
}

/** Rows marked data-flip="<id>" glide to their new place, new rows rise in */
function useFlip<T extends HTMLElement>(key: unknown) {
  const ref = useRef<T>(null);
  const before = useRef<Map<string, Box> | null>(null);
  if (typeof window !== 'undefined' && ref.current) {
    const root = ref.current;
    before.current = new Map(Array.from(root.querySelectorAll<HTMLElement>('[data-flip]'), (el) => [el.dataset.flip!, boxOf(el, root)]));
  }
  useIso(() => {
    const root = ref.current;
    const old = before.current;
    if (!root || !old || prefersReducedMotion()) return;
    root.querySelectorAll<HTMLElement>('[data-flip]').forEach((el, i) => {
      const a = old.get(el.dataset.flip!);
      // Stop an earlier glide, but leave the CSS entrances (kitm-up, kitm-fly) alone
      const anims = el.getAnimations();
      anims.forEach((x) => !('animationName' in x) && x.cancel());
      const b = boxOf(el, root);
      if (!a) {
        if (old.size && !anims.some((x) => 'animationName' in x && x.playState === 'running')) el.animate([{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'none' }], { duration: 380, delay: Math.min(i, 8) * 30, easing: KITM_EASE, fill: 'backwards' });
        return;
      }
      if (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1) return;
      el.animate([{ transform: `translate(${a.x - b.x}px, ${a.y - b.y}px)` }, { transform: 'none' }], { duration: 460, easing: KITM_EASE });
    });
  }, [key]);
  return ref;
}


/* ------------------------------------------------------------------ */
/* Text                                                                */
/* ------------------------------------------------------------------ */

function inline(text: string) {
  return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) => (part.startsWith('**') ? <strong key={i} className="font-semibold text-foreground">{part.slice(2, -2)}</strong> : <Fragment key={i}>{part}</Fragment>));
}

/** Paragraphs, simple lists and bold, enough for chat answers. `caret` ends the last line with a cursor while it streams. */
function RichText({ text, className, caret }: { text: string; className?: string; caret?: boolean }) {
  const blocks = text.split(/\n{2,}/);
  const cursor = caret ? <span className="kitm-caret" aria-hidden /> : null;
  return (
    <div className={cn('space-y-3 text-pretty text-[14.5px] leading-relaxed', className)}>
      {!text && cursor}
      {blocks.map((b, i) => {
        const lines = b.split('\n');
        const last = i === blocks.length - 1;
        if (lines.every((l) => l.startsWith('- '))) {
          return (
            <ul key={i} className="space-y-1.5">
              {lines.map((l, j) => (
                <li key={l} className="relative pl-4 before:absolute before:left-0.5 before:top-[0.65em] before:size-1.5 before:rounded-full before:bg-primary">
                  {inline(l.slice(2))}
                  {last && j === lines.length - 1 && cursor}
                </li>
              ))}
            </ul>
          );
        }
        return (
          <p key={i}>
            {inline(b)}
            {last && cursor}
          </p>
        );
      })}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* Conversation engine                                                 */
/* ------------------------------------------------------------------ */

function useConversation(initial: ChatMessage[], respond: AiChatProps['respond'], fallback: string) {
  const [messages, setMessages] = useState(initial);
  const [state, setState] = useState<'idle' | 'thinking' | 'streaming'>('idle');
  const stopRef = useRef(false);
  const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined);

  useEffect(() => () => clearInterval(timer.current), []);

  const send = useCallback(
    async (prompt: string) => {
      const text = prompt.trim();
      if (!text) return;
      stopRef.current = false;
      const uid = `u${Date.now()}`;
      setMessages((m) => [...m, { id: uid, role: 'user', content: text }]);
      setState('thinking');
      const reply = respond ? await respond(text) : (await new Promise((r) => setTimeout(r, 900)), fallback);
      if (stopRef.current) return setState('idle');
      const aid = `a${Date.now()}`;
      if (prefersReducedMotion()) {
        setMessages((m) => [...m, { id: aid, role: 'assistant', content: reply }]);
        return setState('idle');
      }
      setState('streaming');
      setMessages((m) => [...m, { id: aid, role: 'assistant', content: '' }]);
      let i = 0;
      timer.current = setInterval(() => {
        i = Math.min(reply.length, i + 3);
        setMessages((m) => m.map((x) => (x.id === aid ? { ...x, content: reply.slice(0, i) } : x)));
        if (i >= reply.length || stopRef.current) {
          clearInterval(timer.current);
          setState('idle');
        }
      }, 18);
    },
    [respond, fallback],
  );

  const stop = () => {
    stopRef.current = true;
    clearInterval(timer.current);
    setState('idle');
  };

  return { messages, state, send, stop, setMessages };
}

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

function AssistantAvatar({ assistant, size = 'md' }: { assistant: AiChatProps['assistant']; size?: 'sm' | 'md' | 'lg' }) {
  const s = size === 'sm' ? 'size-7' : size === 'lg' ? 'size-12' : 'size-8';
  if (assistant.avatar) return <span className={cn('shrink-0 [&>*]:size-full', s)}>{assistant.avatar}</span>;
  return (
    <span className={cn('relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-[conic-gradient(from_200deg,var(--primary),color-mix(in_oklab,var(--primary)_40%,var(--foreground)),var(--primary))] text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.25),var(--shadow-sm)]', s)} aria-hidden>
      <Icon name="sparkle" size={size === 'lg' ? 22 : 15} />
    </span>
  );
}

/** Online: a dot that breathes, so the assistant reads as present */
function LiveDot() {
  return (
    <span className="relative grid size-1.5 place-items-center" aria-hidden>
      <span className="absolute size-1.5 animate-ping rounded-full bg-success opacity-60 [animation-duration:2.4s] motion-reduce:hidden" />
      <span className="size-1.5 rounded-full bg-success" />
    </span>
  );
}

function Typing({ label }: { label: string }) {
  return (
    <span className="kitm-in inline-flex items-center gap-2 text-[13px] text-muted-foreground [--kitm-t:.35s]" role="status">
      <span className="flex gap-1" aria-hidden>
        {[0, 1, 2].map((i) => (
          <span key={i} className="size-1.5 animate-kit-pulse rounded-full bg-current" style={{ animationDelay: `${i * 160}ms`, animationDuration: '1s' }} />
        ))}
      </span>
      {label}
    </span>
  );
}

function MessageActions({ text, t }: { text: string; t: Texts }) {
  const [copied, setCopied] = useState(false);
  const [vote, setVote] = useState<'up' | 'down' | null>(null);
  const btn = 'grid size-8 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground';
  return (
    <div className="mt-1.5 flex items-center gap-0.5 opacity-100 transition-opacity sm:opacity-0 sm:group-hover/msg:opacity-100 sm:group-focus-within/msg:opacity-100">
      <button
        type="button"
        className={btn}
        aria-label={copied ? t.copied : t.copy}
        onClick={async () => {
          try {
            await navigator.clipboard.writeText(text);
          } catch {
            /* clipboard blocked */
          }
          setCopied(true);
          setTimeout(() => setCopied(false), 1500);
        }}
      >
        {/* The check draws itself, then turns back into the copy icon */}
        <Icon key={String(copied)} name={copied ? 'check' : 'copy'} size={15} className={copied ? 'kitm-tick text-success' : 'kitm-in [--kitm-t:.25s]'} />
      </button>
      <button type="button" className={cn(btn, vote === 'up' && 'text-foreground')} aria-pressed={vote === 'up'} aria-label={t.good} onClick={() => setVote(vote === 'up' ? null : 'up')}>
        <Icon name="thumbs-up" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', vote === 'up' && '-translate-y-0.5 -rotate-12 scale-110')} />
      </button>
      <button type="button" className={cn(btn, vote === 'down' && 'text-foreground')} aria-pressed={vote === 'down'} aria-label={t.bad} onClick={() => setVote(vote === 'down' ? null : 'down')}>
        <Icon name="thumbs-down" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', vote === 'down' && 'translate-y-0.5 rotate-12 scale-110')} />
      </button>
    </div>
  );
}

function Messages({ messages, state, assistant, t, className }: { messages: ChatMessage[]; state: 'idle' | 'thinking' | 'streaming'; assistant: AiChatProps['assistant']; t: Texts; className?: string }) {
  const end = useRef<HTMLDivElement>(null);
  const first = useRef(true);
  // The conversation so far rises in once, every message after that slides in on its own
  const [scope, enter] = useEnter<HTMLDivElement>();
  const initial = useRef<Set<string> | null>(null);
  if (!initial.current) initial.current = new Set(messages.map((m) => m.id));
  const motion = (m: ChatMessage, i: number) => (initial.current!.has(m.id) ? { className: 'kitm-up', style: { '--kitm-d': `${Math.min(i, 6) * 90}ms` } as React.CSSProperties } : { className: m.role === 'user' ? 'kitm-fly' : 'kitm-in', style: undefined });
  useEffect(() => {
    // Inside a scrolling panel keep the newest message in view, without moving the page
    const box = end.current?.closest<HTMLElement>('.overflow-y-auto');
    if (box) {
      box.scrollTo({ top: box.scrollHeight, behavior: first.current || prefersReducedMotion() ? 'auto' : 'smooth' });
    } else if (!first.current) {
      end.current?.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
    }
    first.current = false;
  }, [messages, state]);
  return (
    <div ref={scope} {...enter} className={cn('space-y-6', className)} role="log" aria-live="polite" aria-relevant="additions">
      {messages.map((m, i) =>
        m.role === 'user' ? (
          <div key={m.id} className={cn('flex justify-end', motion(m, i).className)} style={motion(m, i).style}>
            <div className="max-w-[85%] rounded-2xl rounded-br-md bg-primary px-4 py-2.5 text-[14.5px] leading-relaxed text-primary-foreground shadow-[var(--shadow-sm)] sm:max-w-[75%]">
              <span className="sr-only">{t.you}: </span>
              {m.content}
            </div>
          </div>
        ) : (
          <div key={m.id} className={cn('group/msg flex gap-3', motion(m, i).className)} style={motion(m, i).style}>
            <AssistantAvatar assistant={assistant} />
            <div className="min-w-0 flex-1 pt-1">
              <p className="mb-1 flex items-center gap-2 text-[12.5px] font-medium text-foreground">
                {assistant.name}
                {m.time && <span className="font-normal text-subtle-foreground">{m.time}</span>}
              </p>
              <RichText text={m.content} className="text-foreground/90" caret={state === 'streaming' && i === messages.length - 1} />
              {m.content && !(state === 'streaming' && i === messages.length - 1) && <MessageActions text={m.content} t={t} />}
            </div>
          </div>
        ),
      )}
      {state === 'thinking' && (
        <div className="kitm-in flex gap-3 [--kitm-t:.35s]">
          <span className="relative grid shrink-0 place-items-center">
            <span className="absolute inset-0 animate-ping rounded-full bg-[color-mix(in_oklab,var(--primary)_35%,transparent)] [animation-duration:1.6s] motion-reduce:hidden" aria-hidden />
            <AssistantAvatar assistant={assistant} />
          </span>
          <div className="pt-1.5">
            <Typing label={t.thinking} />
          </div>
        </div>
      )}
      <div ref={end} />
    </div>
  );
}

interface ComposerProps {
  t: Texts;
  onSend: (text: string) => void;
  onStop?: () => void;
  busy?: boolean;
  placeholder?: string;
  models?: string[];
  size?: 'md' | 'lg';
  tools?: boolean;
  autoFocus?: boolean;
  className?: string;
  value?: string;
  onValue?: (v: string) => void;
}

/** Auto growing input. Enter sends, Shift Enter adds a line. */
function Composer({ t, onSend, onStop, busy, placeholder, models, size = 'md', tools, autoFocus, className, value: controlled, onValue }: ComposerProps) {
  const [own, setOwn] = useState('');
  const value = controlled ?? own;
  const setValue = onValue ?? setOwn;
  const [files, setFiles] = useState<string[]>([]);
  const [model, setModel] = useState(models?.[0] ?? '');
  const [web, setWeb] = useState(false);
  const area = useRef<HTMLTextAreaElement>(null);
  const file = useRef<HTMLInputElement>(null);
  const id = useId();

  useEffect(() => {
    const el = area.current;
    if (!el) return;
    el.style.height = 'auto';
    el.style.height = `${Math.min(el.scrollHeight, size === 'lg' ? 240 : 180)}px`;
  }, [value, size]);

  const submit = () => {
    if (busy || !value.trim()) return;
    onSend(value);
    setValue('');
    setFiles([]);
  };

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        submit();
      }}
      className={cn(
        'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] transition-[border-color,box-shadow] focus-within:border-border-strong focus-within:shadow-[var(--highlight),var(--shadow-md),0_0_0_4px_var(--glow)]',
        className,
      )}
    >
      {files.length > 0 && (
        <ul className="flex flex-wrap gap-1.5 px-3 pt-3">
          {files.map((f) => (
            <li key={f} className="kitm-in inline-flex h-8 items-center gap-1.5 rounded-lg border border-border bg-surface pl-2 pr-1 text-[12.5px] text-foreground [--kitm-t:.3s]">
              <Icon name="file" size={14} className="text-muted-foreground" />
              <span className="max-w-[10rem] truncate">{f}</span>
              <button type="button" onClick={() => setFiles(files.filter((x) => x !== f))} aria-label={fill(t.remove, { name: f })} className="grid size-6 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground">
                <Icon name="x" size={12} />
              </button>
            </li>
          ))}
        </ul>
      )}
      <label htmlFor={`${id}-m`} className="sr-only">
        {t.message}
      </label>
      <textarea
        ref={area}
        id={`${id}-m`}
        rows={1}
        value={value}
        autoFocus={autoFocus}
        placeholder={placeholder}
        aria-describedby={`${id}-h`}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
            e.preventDefault();
            submit();
          }
        }}
        className={cn('block w-full resize-none bg-transparent px-4 text-foreground outline-none placeholder:text-subtle-foreground', size === 'lg' ? 'min-h-[5.5rem] pt-4 text-[16px] leading-relaxed' : 'min-h-[3rem] pt-3.5 text-[15px] leading-relaxed')}
      />
      <p id={`${id}-h`} className="sr-only">
        {t.enterHint}
      </p>
      <div className="flex items-center gap-1 p-2 pt-1">
        <input
          ref={file}
          type="file"
          multiple
          className="sr-only"
          tabIndex={-1}
          aria-hidden
          onChange={(e) => {
            const names = Array.from(e.target.files ?? []).map((f) => f.name);
            setFiles((fs) => Array.from(new Set([...fs, ...names])));
            e.target.value = '';
          }}
        />
        <button type="button" onClick={() => file.current?.click()} aria-label={t.attach} className="grid size-11 place-items-center sm:size-10 rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
          <Icon name="paperclip" size={18} />
        </button>
        {tools && (
          <button type="button" aria-pressed={web} onClick={() => setWeb(!web)} className={cn('inline-flex h-11 items-center gap-1.5 rounded-lg border px-2.5 text-[13px] font-medium transition-colors sm:h-10', web ? 'border-transparent bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'border-border text-muted-foreground hover:text-foreground')}>
            <Icon name="globe" size={15} />
            <span className="max-sm:sr-only">{t.webSearch}</span>
          </button>
        )}
        {models && models.length > 0 && (
          <span className="relative ml-1 inline-flex">
            <label htmlFor={`${id}-model`} className="sr-only">
              {t.model}
            </label>
            <select id={`${id}-model`} value={model} onChange={(e) => setModel(e.target.value)} className="h-11 cursor-pointer appearance-none rounded-lg bg-transparent pl-2.5 pr-7 sm:h-10 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
              {models.map((m) => (
                <option key={m}>{m}</option>
              ))}
            </select>
            <Icon name="chevron-down" size={13} className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-subtle-foreground" />
          </span>
        )}
        <span className="ml-auto" />
        {busy && onStop ? (
          <button type="button" onClick={onStop} aria-label={t.stop} className="grid size-11 place-items-center rounded-xl sm:size-10 bg-foreground text-background shadow-[var(--shadow-sm)] transition-transform active:scale-95">
            <Icon name="stop" size={15} className="fill-current" />
          </button>
        ) : (
          <button type="submit" disabled={!value.trim() || busy} aria-label={t.send} className="group/send grid size-11 place-items-center overflow-hidden rounded-xl bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] transition-[transform,opacity] duration-300 ease-[var(--ease-kit)] active:scale-95 disabled:scale-90 disabled:opacity-35 sm:size-10 motion-reduce:disabled:scale-100">
            {/* The arrow nudges up on hover, as if it already wants to go */}
            <Icon name="arrow-up" size={18} strokeWidth={2} className="transition-transform duration-300 ease-[var(--ease-kit)] group-enabled/send:group-hover/send:-translate-y-0.5" />
          </button>
        )}
      </div>
    </form>
  );
}

function Suggestions({ items, onPick, className }: { items: NonNullable<AiChatProps['suggestions']>; onPick: (p: string) => void; className?: string }) {
  const [scope, enter] = useEnter<HTMLUListElement>();
  return (
    <ul ref={scope} {...enter} className={cn('flex flex-wrap gap-2', className)}>
      {items.map((s, i) => (
        <li key={s.label} className="kitm-up" style={{ '--kitm-d': `${120 + i * 60}ms` } as React.CSSProperties}>
          <button type="button" onClick={() => onPick(s.prompt ?? s.label)} className="inline-flex min-h-10 items-center gap-2 rounded-full border border-border bg-background px-3.5 text-left text-[13px] text-muted-foreground shadow-[var(--shadow-sm)] transition-[border-color,color,transform] duration-200 hover:-translate-y-px hover:border-border-strong hover:text-foreground motion-reduce:hover:translate-y-0">
            {s.icon && <Icon name={s.icon} size={15} className="shrink-0" />}
            {s.label}
          </button>
        </li>
      ))}
    </ul>
  );
}

/* ------------------------------------------------------------------ */
/* chat-window                                                         */
/* ------------------------------------------------------------------ */

function ChatWindow({ t, assistant, messages = [], suggestions, placeholder, respond, models, className }: P) {
  const c = useConversation(messages, respond, t.fallback);
  return (
    <div className={cn('kit-glow flex min-h-dvh items-center justify-center bg-surface p-0 sm:p-6 lg:p-10', className)}>
      <section className="flex h-dvh w-full max-w-[52rem] flex-col overflow-hidden bg-background sm:h-[min(48rem,calc(100dvh-3rem))] sm:rounded-2xl sm:border sm:border-border sm:shadow-[var(--highlight),var(--shadow-lg)]">
        <header className="flex h-16 shrink-0 items-center gap-3 border-b border-border px-4 sm:px-5">
          <AssistantAvatar assistant={assistant} />
          <div className="min-w-0 flex-1">
            <h1 className="truncate text-[15px] font-semibold tracking-[-0.01em] text-foreground">{assistant.name}</h1>
            <p className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
              <LiveDot />
              {t.online}
              {assistant.model && <span className="text-subtle-foreground">· {assistant.model}</span>}
            </p>
          </div>
          <button type="button" aria-label={t.newChat} onClick={() => c.setMessages([])} className="grid size-10 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
            <Icon name="edit" size={18} />
          </button>
        </header>
        <div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto px-4 py-6 [mask-image:linear-gradient(to_bottom,transparent,#000_1.5rem)] sm:px-6">
          {c.messages.length === 0 && suggestions ? (
            <div className="grid h-full place-items-center text-center">
              <div className="kitm-in">
                <AssistantAvatar assistant={assistant} size="lg" />
                <p className="mt-4 text-[17px] font-semibold text-foreground">{assistant.name}</p>
                <Suggestions items={suggestions} onPick={c.send} className="mt-5 justify-center" />
              </div>
            </div>
          ) : (
            <Messages messages={c.messages} state={c.state} assistant={assistant} t={t} />
          )}
        </div>
        <div className="relative shrink-0 px-3 pb-3 pt-2 before:pointer-events-none before:absolute before:inset-x-0 before:-top-8 before:h-8 before:bg-[linear-gradient(to_top,var(--background),transparent)] sm:px-5 sm:pb-5">
          {c.messages.length > 0 && suggestions && c.state === 'idle' && (
            <div className="kit-no-scrollbar max-sm:kit-fade-x -mx-3 mb-2.5 overflow-x-auto px-3 sm:mx-0 sm:px-0">
              <Suggestions items={suggestions.slice(0, 3)} onPick={c.send} className="flex-nowrap sm:flex-wrap [&_button]:whitespace-nowrap" />
            </div>
          )}
          <Composer t={t} onSend={c.send} onStop={c.stop} busy={c.state !== 'idle'} placeholder={placeholder ?? fill(t.messageTo, { name: assistant.name })} models={models} />
          <p className="mt-2 text-center text-[11.5px] text-subtle-foreground">{t.disclaimer}</p>
        </div>
      </section>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* prompt-composer                                                     */
/* ------------------------------------------------------------------ */

function PromptComposer({ t, assistant, title, description, suggestions = [], placeholder, models, respond, className }: P) {
  const c = useConversation([], respond, t.fallback);
  const [draft, setDraft] = useState('');
  const started = c.messages.length > 0;
  const spot = useSpotlight<HTMLButtonElement>();
  return (
    <div className={cn('kit-glow kit-dots min-h-dvh bg-background', className)}>
      <div className={cn('kit-container flex min-h-dvh flex-col py-10 sm:py-16', !started && 'justify-center')}>
        <div className="mx-auto w-full max-w-[46rem]">
          {!started && (
            <div className="kit-rise text-center">
              <span className="inline-flex">
                <AssistantAvatar assistant={assistant} size="lg" />
              </span>
              {title && <h1 className="mt-6 text-balance text-h2 font-semibold text-foreground">{title}</h1>}
              {description && <p className="mx-auto mt-3 max-w-[32rem] text-balance text-lead text-muted-foreground">{description}</p>}
            </div>
          )}
          {started && <Messages messages={c.messages} state={c.state} assistant={assistant} t={t} className="mb-8" />}
          <div className={cn('kit-rise [--kit-delay:120ms]', !started && 'mt-9', started && 'sticky bottom-4')}>
            <Composer t={t} onSend={c.send} onStop={c.stop} busy={c.state !== 'idle'} placeholder={placeholder} models={models} tools size={started ? 'md' : 'lg'} value={draft} onValue={setDraft} />
          </div>
          {!started && (
            <ul className="mt-5 grid gap-2.5 sm:grid-cols-2">
              {suggestions.map((s, i) => (
                <li key={s.label} className="kit-rise" style={{ '--kit-delay': `${240 + i * 70}ms` } as React.CSSProperties}>
                  <button
                    type="button"
                    onClick={() => setDraft(s.prompt ?? s.label)}
                    onPointerMove={spot}
                    className="kit-spotlight group flex h-full w-full items-start gap-3 rounded-xl border border-border bg-card p-3.5 text-left shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:border-border-strong hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0"
                  >
                    <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground transition-colors group-hover:text-primary">
                      <Icon name={s.icon ?? 'sparkle'} size={16} />
                    </span>
                    <span className="min-w-0">
                      <span className="block text-[13.5px] font-medium text-foreground">{s.label}</span>
                      {s.prompt && <span className="mt-0.5 line-clamp-2 block text-[12.5px] text-muted-foreground">{s.prompt}</span>}
                    </span>
                  </button>
                </li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* agent-steps                                                         */
/* ------------------------------------------------------------------ */

const STEP_STYLE: Record<AgentStep['status'], string> = {
  done: 'border-transparent bg-[color-mix(in_oklab,var(--success)_14%,transparent)] text-success',
  running: 'border-primary bg-background text-primary',
  pending: 'border-border bg-background text-subtle-foreground',
  approval: 'border-transparent bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-[color-mix(in_oklab,var(--warning)_62%,var(--foreground))]',
  error: 'border-transparent bg-[color-mix(in_oklab,var(--destructive)_12%,transparent)] text-destructive',
};

function AgentSteps({ t, assistant, task, steps: initial = [], className }: P) {
  const [steps, setSteps] = useState(initial);
  const [open, setOpen] = useState<number | null>(initial.findIndex((s) => s.status === 'running'));
  const [stopped, setStopped] = useState(false);
  const id = useId();
  const done = steps.filter((s) => s.status === 'done').length;
  const runningIndex = steps.findIndex((s) => s.status === 'running');
  const approvalIndex = steps.findIndex((s) => s.status === 'approval');
  const finished = done === steps.length;

  // The running step finishes after a moment, the next one starts, until an approval is needed
  useEffect(() => {
    if (stopped || runningIndex < 0 || prefersReducedMotion()) return;
    const timer = setTimeout(() => {
      setSteps((all) =>
        all.map((s, i) => {
          if (i === runningIndex) return { ...s, status: 'done' };
          if (i === runningIndex + 1 && s.status === 'pending') return { ...s, status: 'running' };
          return s;
        }),
      );
      setOpen(runningIndex + 1);
    }, 2200);
    return () => clearTimeout(timer);
  }, [runningIndex, stopped]);

  const decide = (approve: boolean) =>
    setSteps((all) => all.map((s, i) => (i === approvalIndex ? { ...s, status: approve ? 'done' : 'error' } : i === approvalIndex + 1 && s.status === 'pending' ? { ...s, status: 'running' } : s)));

  const label = (s: AgentStep['status']) => ({ done: t.done, running: t.running, pending: t.pending, approval: t.approval, error: t.failed })[s];
  const [stepScope, stepEnter] = useEnter<HTMLOListElement>();

  return (
    <div className={cn('min-h-dvh bg-surface py-8 sm:py-14', className)}>
      <div className="mx-auto w-full max-w-[44rem] px-4 sm:px-6">
        <section className="overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-lg)]">
          <header className="relative border-b border-border p-5 sm:p-6">
            <div className="flex items-start gap-3">
              <AssistantAvatar assistant={assistant} />
              <div className="min-w-0 flex-1">
                <p className="text-[12.5px] text-muted-foreground">
                  {assistant.name} · {task?.started}
                </p>
                <h1 className="mt-0.5 text-balance text-[18px] font-semibold tracking-[-0.02em] text-foreground">{task?.title}</h1>
              </div>
              {!finished && !stopped && (
                <Button size="sm" variant="outline" iconLeft="stop" onClick={() => setStopped(true)} className="max-sm:hidden">
                  {t.stopAgent}
                </Button>
              )}
            </div>
            <div className="mt-5 flex items-center gap-3">
              <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-track" role="progressbar" aria-valuemin={0} aria-valuemax={steps.length} aria-valuenow={done} aria-label={fill(t.progress, { done, total: steps.length })}>
                {/* scaleX instead of width, with a light running over the part that is done while the agent works */}
                <div className="relative h-full origin-left overflow-hidden rounded-full bg-primary transition-transform duration-700 ease-[var(--ease-kit)]" style={{ transform: `scaleX(${round(done / Math.max(1, steps.length), 4)})` }}>
                  {!finished && !stopped && <span className="kit-shine absolute inset-0 animate-[kit-shine_1.8s_linear_infinite] motion-reduce:hidden" aria-hidden />}
                </div>
              </div>
              <span className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
                <span key={done} className="kitm-in inline-block [--kitm-t:.3s]">{done}</span>
                {fill(t.progress, { done: '', total: steps.length })}
              </span>
            </div>
          </header>
          <ol ref={stepScope} {...stepEnter} className="p-3 sm:p-4">
            {steps.map((s, i) => {
              const isOpen = open === i;
              const hasDetails = !!(s.detail || s.output?.length);
              return (
                <li key={s.title} className="kitm-up relative" style={{ '--kitm-d': `${i * 70}ms` } as React.CSSProperties}>
                  {i < steps.length - 1 && (
                    <span className="absolute bottom-0 left-[1.6rem] top-11 w-px overflow-hidden bg-border" aria-hidden>
                      {/* The line to the next step fills from the top once this one is done */}
                      <span className={cn('block size-full origin-top bg-[color-mix(in_oklab,var(--success)_55%,var(--border))] transition-transform duration-700 ease-[var(--ease-kit)]', s.status === 'done' ? 'scale-y-100' : 'scale-y-0')} />
                    </span>
                  )}
                  <div className={cn('flex items-start gap-3 rounded-xl p-2 transition-colors duration-300', s.status === 'running' && 'bg-surface')}>
                    <span className={cn('relative mt-0.5 grid size-8 shrink-0 place-items-center rounded-full border transition-colors duration-300', STEP_STYLE[s.status])}>
                      {s.status === 'running' && <span className="absolute -inset-1 animate-ping rounded-full border border-primary opacity-40 [animation-duration:1.8s] motion-reduce:hidden" aria-hidden />}
                      {s.status === 'done' && <Icon name="check" size={15} strokeWidth={2.2} className="kitm-tick" />}
                      {s.status === 'running' && <Icon name="loader" size={15} className="animate-kit-spin [--kit-duration:0.9s]" />}
                      {s.status === 'pending' && <span className="text-[12px] font-semibold tabular-nums">{i + 1}</span>}
                      {s.status === 'approval' && <Icon name="flag" size={14} />}
                      {s.status === 'error' && <Icon name="x" size={14} />}
                    </span>
                    <div className="min-w-0 flex-1">
                      <div className="flex min-h-9 flex-wrap items-center gap-x-2 gap-y-0.5">
                        <h2 className={cn('text-[14px] font-medium', s.status === 'pending' ? 'text-muted-foreground' : 'text-foreground')}>{s.title}</h2>
                        {s.tool && (
                          <span className="inline-flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[11.5px] text-muted-foreground">
                            <Icon name="terminal" size={11} />
                            {s.tool}
                          </span>
                        )}
                        <span className="sr-only">{label(s.status)}</span>
                        {s.duration && s.status === 'done' && <span className="ml-auto text-[12px] tabular-nums text-subtle-foreground">{s.duration}</span>}
                        {hasDetails && (
                          <button type="button" aria-expanded={isOpen} aria-controls={`${id}-${i}`} onClick={() => setOpen(isOpen ? null : i)} aria-label={isOpen ? t.hideDetails : t.showDetails} className={cn('grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground', !(s.duration && s.status === 'done') && 'ml-auto')}>
                            <Icon name="chevron-down" size={15} className={cn('transition-transform duration-200', isOpen && 'rotate-180')} />
                          </button>
                        )}
                      </div>
                      {s.status === 'running' && <Typing label={t.running} />}
                      {hasDetails && (
                        <div id={`${id}-${i}`} className={cn('grid transition-[grid-template-rows] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', isOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]')}>
                        <div className={cn('min-h-0 overflow-hidden transition-opacity duration-300', isOpen ? 'opacity-100' : 'opacity-0')} inert={!isOpen}>
                        <div className="pt-2">
                          {s.detail && <p className="text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.detail}</p>}
                          {s.output && (
                            <div className="mt-2 rounded-lg border border-border bg-surface p-3 text-[12.5px] leading-relaxed text-foreground">
                              {s.output.map((line) => (
                                <p key={line} className="flex gap-2">
                                  <span className="select-none text-subtle-foreground" aria-hidden>
                                    ›
                                  </span>
                                  <span className="min-w-0 break-words">{line}</span>
                                </p>
                              ))}
                            </div>
                          )}
                        </div>
                        </div>
                        </div>
                      )}
                      {s.status === 'approval' && !stopped && (
                        <div className="kitm-in mt-3 flex flex-col gap-2 sm:flex-row">
                          <Button size="sm" iconLeft="check" onClick={() => decide(true)}>
                            {t.approve}
                          </Button>
                          <Button size="sm" variant="outline" onClick={() => decide(false)}>
                            {t.decline}
                          </Button>
                        </div>
                      )}
                    </div>
                  </div>
                  {i < steps.length - 1 && <div className="h-2" aria-hidden />}
                </li>
              );
            })}
          </ol>
          {(stopped || finished || task?.files?.length) && (
            <footer className="border-t border-border bg-surface p-4 sm:px-6">
              {(stopped || finished) && (
                <p className={cn('kitm-in mb-3 flex items-center gap-2 text-[13.5px]', stopped ? 'text-muted-foreground' : 'text-success')} role="status">
                  <Icon name={stopped ? 'stop' : 'check-circle'} size={16} className={stopped ? '' : 'kitm-tick'} />
                  {stopped ? t.stopped : t.finished}
                </p>
              )}
              {task?.files && (
                <>
                  <p className="text-[12px] font-medium text-subtle-foreground">{t.files}</p>
                  <ul className="mt-2 flex flex-wrap gap-2">
                    {task.files.map((f) => (
                      <li key={f}>
                        <a href="#" className="inline-flex h-9 items-center gap-2 rounded-lg border border-border bg-card px-3 text-[13px] text-foreground shadow-[var(--shadow-sm)] hover:border-border-strong">
                          <Icon name="file" size={15} className="text-muted-foreground" />
                          {f}
                        </a>
                      </li>
                    ))}
                  </ul>
                </>
              )}
              {!finished && !stopped && (
                <Button size="sm" variant="outline" iconLeft="stop" onClick={() => setStopped(true)} className="mt-4 w-full sm:hidden">
                  {t.stopAgent}
                </Button>
              )}
            </footer>
          )}
        </section>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* history-sidebar                                                     */
/* ------------------------------------------------------------------ */

function HistoryList({ history, t, onPick, active }: { history: NonNullable<AiChatProps['history']>; t: Texts; onPick: (id: string) => void; active: string }) {
  const [q, setQ] = useState('');
  const id = useId();
  const list = history.filter((h) => h.title.toLowerCase().includes(q.trim().toLowerCase()));
  const groups = Array.from(new Set(list.map((h) => h.group)));
  // The highlight glides to the picked chat, the list closes ranks while searching
  const nav = useGlide<HTMLElement>(active);
  const rows = useFlip<HTMLElement>(list);
  return (
    <>
      <div className="relative mx-3 flex h-9 items-center">
        <label htmlFor={`${id}-s`} className="sr-only">
          {t.searchChats}
        </label>
        <Icon name="search" size={15} className="pointer-events-none absolute left-2.5 text-muted-foreground" />
        <input id={`${id}-s`} type="search" value={q} onChange={(e) => setQ(e.target.value)} placeholder={t.searchChats} className="h-full w-full rounded-lg border border-border bg-background pl-8 pr-2 text-[13px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-border-strong" />
      </div>
      <nav
        ref={(el) => {
          nav.current = el;
          rows.current = el;
        }}
        aria-label={t.history}
        className="kit-scrollbar relative mt-2 min-h-0 flex-1 overflow-y-auto px-3 pb-3"
      >
        {groups.map((g) => (
          <div key={g} className="mt-3">
            <p className="px-2.5 pb-1 text-[11.5px] font-medium text-subtle-foreground">{g}</p>
            <ul className="space-y-0.5">
              {list
                .filter((h) => h.group === g)
                .map((h) => (
                  <li key={h.id} data-flip={h.id}>
                    <button
                      type="button"
                      onClick={() => onPick(h.id)}
                      aria-current={h.id === active ? 'page' : undefined}
                      className={cn('relative flex min-h-9 w-full items-center rounded-lg px-2.5 text-left text-[13px] transition-colors duration-200', h.id === active ? 'font-medium text-foreground' : 'text-muted-foreground hover:bg-[color-mix(in_oklab,var(--accent)_60%,transparent)] hover:text-foreground')}
                    >
                      {h.id === active && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-accent" />}
                      <span className="relative truncate">{h.title}</span>
                    </button>
                  </li>
                ))}
            </ul>
          </div>
        ))}
        {!list.length && <p className="px-2.5 py-6 text-[13px] text-muted-foreground">{t.noChats}</p>}
      </nav>
    </>
  );
}

function HistorySidebar({ t, assistant, messages = [], history = [], placeholder, respond, models, className }: P) {
  const c = useConversation(messages, respond, t.fallback);
  const [active, setActive] = useState(history.find((h) => h.active)?.id ?? history[0]?.id ?? '');
  const [sheet, setSheet] = useState(false);
  const dlg = useRef<HTMLDialogElement>(null);
  useEffect(() => {
    const d = dlg.current;
    if (!d) return;
    if (sheet && !d.open) d.showModal();
    if (!sheet && d.open) d.close();
  }, [sheet]);
  const current = history.find((h) => h.id === active);
  const pick = (hid: string) => {
    setActive(hid);
    setSheet(false);
    if (hid !== (history.find((h) => h.active)?.id ?? history[0]?.id)) c.setMessages([]);
    else c.setMessages(messages);
  };
  const newChat = (
    <Button variant="outline" size="sm" iconLeft="edit" className="w-full justify-start" onClick={() => (setActive(''), c.setMessages([]), setSheet(false))}>
      {t.newChat}
    </Button>
  );
  return (
    <div className={cn('flex h-dvh bg-background', className)}>
      <aside className="hidden w-[17rem] shrink-0 flex-col border-r border-border bg-surface md:flex">
        <div className="flex h-14 items-center gap-2.5 px-4">
          <AssistantAvatar assistant={assistant} size="sm" />
          <span className="text-[14px] font-semibold text-foreground">{assistant.name}</span>
        </div>
        <div className="px-3 pb-3">{newChat}</div>
        <HistoryList history={history} t={t} onPick={pick} active={active} />
      </aside>
      <dialog
        ref={dlg}
        onClose={() => setSheet(false)}
        onClick={(e) => e.target === dlg.current && setSheet(false)}
        aria-label={t.history}
        className="m-0 h-dvh max-h-none w-[min(20rem,86vw)] max-w-none border-r border-border bg-surface p-0 text-foreground shadow-[var(--shadow-lg)] backdrop:bg-scrim open:flex open:flex-col open:animate-[kit-rise_0.3s_var(--ease-kit)]"
      >
        <div className="flex h-14 shrink-0 items-center gap-2 px-3">
          <span className="flex-1 px-1 text-[14px] font-semibold">{t.history}</span>
          <button type="button" onClick={() => setSheet(false)} aria-label={t.closeHistory} className="grid size-10 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
            <Icon name="x" size={18} />
          </button>
        </div>
        <div className="px-3 pb-3">{newChat}</div>
        <HistoryList history={history} t={t} onPick={pick} active={active} />
      </dialog>
      <main className="flex min-w-0 flex-1 flex-col">
        <header className="flex h-14 shrink-0 items-center gap-2 border-b border-border px-2 sm:px-4">
          <button type="button" onClick={() => setSheet(true)} aria-label={t.openHistory} aria-expanded={sheet} className="grid size-11 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground md:hidden">
            <Icon name="panel-left" size={19} />
          </button>
          <h1 className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">{current?.title ?? t.newChat}</h1>
          {assistant.model && <span className="rounded-md bg-muted px-2 py-1 text-[12px] text-muted-foreground">{assistant.model}</span>}
        </header>
        <div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto [mask-image:linear-gradient(to_bottom,transparent,#000_2rem)]">
          <div key={active || 'new'} className="kitm-in mx-auto max-w-[44rem] px-4 py-8 [--kitm-t:.45s] sm:px-6">
            {c.messages.length ? (
              <Messages messages={c.messages} state={c.state} assistant={assistant} t={t} />
            ) : (
              <div className="grid min-h-[40dvh] place-items-center text-center">
                <div>
                  <AssistantAvatar assistant={assistant} size="lg" />
                  <p className="mt-4 text-[17px] font-semibold text-foreground">{t.newChat}</p>
                </div>
              </div>
            )}
          </div>
        </div>
        <div className="mx-auto w-full max-w-[44rem] shrink-0 px-3 pb-3 sm:px-6 sm:pb-5">
          <Composer t={t} onSend={c.send} onStop={c.stop} busy={c.state !== 'idle'} placeholder={placeholder ?? fill(t.messageTo, { name: assistant.name })} models={models} />
        </div>
      </main>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* answer-with-sources                                                 */
/* ------------------------------------------------------------------ */

function Favicon({ site }: { site: string }) {
  return (
    <span className="grid size-5 shrink-0 place-items-center rounded-[5px] bg-[color-mix(in_oklab,var(--primary)_16%,var(--muted))] text-[10px] font-bold uppercase text-foreground" aria-hidden>
      {site.replace(/^www\./, '')[0]}
    </span>
  );
}

function AnswerWithSources({ t, assistant, question, answer = [], sources = [], related = [], respond, placeholder, className }: P) {
  const [hot, setHot] = useState<number | null>(null);
  // Which citation the pointer or focus is on: its source shows as a small card above it
  const [peek, setPeek] = useState<string | null>(null);
  const c = useConversation([], respond, t.fallback);
  const uid = useId();
  const [answerScope, answerEnter] = useEnter<HTMLDivElement>();
  return (
    <div className={cn('min-h-dvh bg-background', className)}>
      <article className="mx-auto max-w-[46rem] px-4 pb-40 pt-10 sm:px-6 sm:pt-16">
        <h1 className="kit-rise text-balance text-[clamp(1.625rem,1.3rem+1.4vw,2.375rem)] font-semibold leading-tight tracking-[-0.035em] text-foreground">{question}</h1>

        <section aria-labelledby={`${uid}-s`} className="kit-rise mt-8 [--kit-delay:80ms]">
          <h2 id={`${uid}-s`} className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
            <Icon name="layers" size={15} className="text-muted-foreground" />
            {t.sources}
          </h2>
          <ol className="kit-no-scrollbar max-sm:kit-fade-x -mx-4 mt-3 flex gap-2.5 overflow-x-auto px-4 sm:mx-0 sm:grid sm:grid-cols-2 sm:overflow-visible sm:px-0 lg:grid-cols-4">
            {sources.map((s, i) => (
              <li key={s.id} className="kit-rise w-[15rem] shrink-0 sm:w-auto" style={{ '--kit-delay': `${140 + i * 70}ms` } as React.CSSProperties}>
                <a
                  href={s.href}
                  onPointerEnter={() => setHot(s.id)}
                  onPointerLeave={() => setHot(null)}
                  onFocus={() => setHot(s.id)}
                  onBlur={() => setHot(null)}
                  className={cn('flex h-full flex-col rounded-xl border bg-card p-3 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,transform] duration-300 ease-[var(--ease-kit)]', hot === s.id ? '-translate-y-0.5 border-primary shadow-[var(--highlight),0_0_0_3px_var(--glow)] motion-reduce:translate-y-0' : 'border-border hover:border-border-strong')}
                >
                  <span className="line-clamp-2 text-[13px] font-medium leading-snug text-foreground">{s.title}</span>
                  <span className="mt-auto flex items-center gap-1.5 pt-3 text-[12px] text-muted-foreground">
                    <Favicon site={s.site} />
                    <span className="truncate">{s.site}</span>
                    <span className="ml-auto tabular-nums text-subtle-foreground">{s.id}</span>
                  </span>
                </a>
              </li>
            ))}
          </ol>
        </section>

        <section aria-labelledby={`${uid}-a`} className="kit-rise mt-10 [--kit-delay:160ms]">
          <h2 id={`${uid}-a`} className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
            <AssistantAvatar assistant={assistant} size="sm" />
            {t.answer}
          </h2>
          <div ref={answerScope} {...answerEnter} className="mt-4 space-y-4 text-pretty text-[16px] leading-[1.7] text-foreground/90">
            {answer.map((p, i) => (
              <p key={i} className="kitm-up" style={{ '--kitm-d': `${300 + i * 120}ms` } as React.CSSProperties}>
                {inline(p.text)}
                {p.cites?.map((n) => {
                  const s = sources.find((x) => x.id === n);
                  const key = `${i}-${n}`;
                  return (
                    <span key={n} className="relative inline-block">
                      <a
                        href={s?.href ?? '#'}
                        aria-label={`${fill(t.source, { n })}: ${s?.title ?? ''}`}
                        onPointerEnter={() => (setHot(n), setPeek(key))}
                        onPointerLeave={() => (setHot(null), setPeek(null))}
                        onFocus={() => (setHot(n), setPeek(key))}
                        onBlur={() => (setHot(null), setPeek(null))}
                        className={cn('relative -top-px ml-1 inline-grid h-5 min-w-5 place-items-center rounded-md px-1 align-middle text-[11px] font-semibold tabular-nums transition-colors', hot === n ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-accent hover:text-foreground')}
                      >
                        {n}
                      </a>
                      {/* Preview of the source, fine pointers and keyboard; on touch the source cards above do the same */}
                      {peek === key && s && (
                        <span aria-hidden className="kitm-in pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 hidden w-64 -translate-x-1/2 rounded-xl border border-border bg-popover p-3 text-left text-[12.5px] leading-snug shadow-[var(--highlight),var(--shadow-lg)] [--kitm-t:.25s] sm:block">
                          <span className="flex items-center gap-1.5 text-muted-foreground">
                            <Favicon site={s.site} />
                            <span className="truncate">{s.site}</span>
                          </span>
                          <span className="mt-1.5 block font-medium text-foreground">{s.title}</span>
                          <span className="mt-1 line-clamp-3 block text-muted-foreground">{s.snippet}</span>
                        </span>
                      )}
                    </span>
                  );
                })}
              </p>
            ))}
          </div>
          <MessageActions text={answer.map((a) => a.text).join('\n\n')} t={t} />
        </section>

        {c.messages.length > 0 && <Messages messages={c.messages} state={c.state} assistant={assistant} t={t} className="mt-10 border-t border-border pt-10" />}

        {related.length > 0 && c.messages.length === 0 && (
          <section aria-labelledby={`${uid}-r`} className="mt-10">
            <h2 id={`${uid}-r`} className="flex items-center gap-2 text-[13px] font-semibold text-foreground">
              <Icon name="list" size={15} className="text-muted-foreground" />
              {t.related}
            </h2>
            <ul className="mt-2 divide-y divide-border border-y border-border">
              {related.map((r) => (
                <li key={r}>
                  <button type="button" onClick={() => c.send(r)} className="group flex min-h-12 w-full items-center gap-3 py-2 text-left text-[14.5px] text-foreground">
                    <span className="flex-1 transition-transform duration-300 ease-[var(--ease-kit)] group-hover:translate-x-1 motion-reduce:group-hover:translate-x-0">{r}</span>
                    <Icon name="plus" size={16} className="text-subtle-foreground transition-transform group-hover:rotate-90 group-hover:text-foreground" />
                  </button>
                </li>
              ))}
            </ul>
          </section>
        )}
      </article>
      <div className="fixed inset-x-0 bottom-0 bg-[linear-gradient(to_top,var(--background)_55%,transparent)] px-3 pb-3 pt-10 sm:pb-6">
        <div className="mx-auto max-w-[46rem]">
          <Composer t={t} onSend={c.send} onStop={c.stop} busy={c.state !== 'idle'} placeholder={placeholder ?? t.followUp} />
        </div>
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* voice-widget                                                        */
/* ------------------------------------------------------------------ */

function Wave({ active }: { active: boolean }) {
  const bars = [0.35, 0.6, 0.9, 0.55, 1, 0.7, 0.45, 0.8, 0.5, 0.3, 0.65, 0.4];
  return (
    <span className="flex h-10 items-center gap-[3px]" aria-hidden>
      <style>{'@keyframes kit-wave{0%,100%{transform:scaleY(.35)}50%{transform:scaleY(1)}}'}</style>
      {bars.map((b, i) => (
        <span
          key={i}
          className={cn('w-[3px] rounded-full transition-[height,opacity,background-color] duration-300 motion-reduce:animate-none', active ? 'bg-primary' : 'bg-border-strong')}
          style={{ height: `${Math.round(b * 100)}%`, animation: active ? `kit-wave ${0.7 + (i % 4) * 0.15}s ease-in-out ${i * 60}ms infinite` : undefined, transform: active ? undefined : 'scaleY(0.45)' }}
        />
      ))}
    </span>
  );
}

function VoiceWidget({ t, assistant, suggestions = [], defaultOpen = false, respond, className }: P) {
  const [open, setOpen] = useState(defaultOpen);
  const [listening, setListening] = useState(false);
  const [heard, setHeard] = useState('');
  const [nudge, setNudge] = useState(false);
  const c = useConversation([], respond ?? (() => t.voiceReply), t.voiceReply);
  // A short hello next to the closed launcher after a moment, once
  useEffect(() => {
    if (open) return setNudge(false);
    const timer = setTimeout(() => setNudge(true), 1800);
    return () => clearTimeout(timer);
  }, [open]);
  const panel = useRef<HTMLDivElement>(null);
  const launcher = useRef<HTMLButtonElement>(null);
  const id = useId();

  useEffect(() => {
    if (!listening) return;
    if (prefersReducedMotion()) {
      setHeard(t.heard);
      return;
    }
    let i = 0;
    const words = t.heard.split(' ');
    const timer = setInterval(() => {
      i++;
      setHeard(words.slice(0, i).join(' '));
      if (i >= words.length) {
        clearInterval(timer);
        setTimeout(() => {
          setListening(false);
          c.send(t.heard);
          setHeard('');
        }, 500);
      }
    }, 260);
    return () => clearInterval(timer);
  }, [listening]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        setOpen(false);
        launcher.current?.focus();
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open]);

  return (
    <div className={cn('fixed bottom-4 right-4 z-50 flex flex-col items-end gap-3 sm:bottom-6 sm:right-6', className)}>
      <div
        ref={panel}
        id={id}
        role="dialog"
        aria-label={assistant.name}
        inert={!open}
        className={cn(
          'flex max-h-[min(36rem,calc(100dvh-7rem))] w-[min(23rem,calc(100vw-2rem))] origin-bottom-right flex-col overflow-hidden rounded-2xl border border-border bg-popover shadow-[var(--highlight),var(--shadow-lg)] transition-[opacity,transform,visibility] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none',
          open ? 'visible scale-100 opacity-100' : 'invisible translate-y-2 scale-95 opacity-0',
        )}
      >
        <header className="relative flex items-center gap-3 overflow-hidden border-b border-border p-4">
          <div aria-hidden data-clip-ok className="pointer-events-none absolute -right-10 -top-12 size-36 rounded-full bg-[var(--glow)] blur-2xl" />
          <AssistantAvatar assistant={assistant} />
          <div className="relative min-w-0 flex-1">
            <p className="truncate text-[14.5px] font-semibold text-foreground">{assistant.name}</p>
            <p className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
              <LiveDot />
              {t.online}
            </p>
          </div>
          <button type="button" onClick={() => (setOpen(false), launcher.current?.focus())} aria-label={t.closeAssistant} className="relative grid size-10 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
            <Icon name="chevron-down" size={18} />
          </button>
        </header>
        <div className="kit-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
          {c.messages.length === 0 ? (
            <div>
              <p className="text-pretty text-[14px] leading-relaxed text-foreground/90">{t.tapToTalk}</p>
              <ul className="mt-3 grid gap-1.5">
                {suggestions.map((s, i) => (
                  <li key={s.label} className={open ? 'kitm-up' : ''} style={{ '--kitm-d': `${150 + i * 60}ms` } as React.CSSProperties}>
                    <button type="button" onClick={() => c.send(s.prompt ?? s.label)} className="flex min-h-11 w-full items-center gap-2.5 rounded-xl border border-border bg-background px-3 text-left text-[13.5px] text-foreground transition-colors hover:border-border-strong">
                      {s.icon && <Icon name={s.icon} size={16} className="text-muted-foreground" />}
                      {s.label}
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          ) : (
            <Messages messages={c.messages} state={c.state} assistant={assistant} t={t} className="space-y-4 [&_.size-8]:size-7" />
          )}
        </div>
        <div className="border-t border-border p-3">
          <div className={cn('mb-3 flex flex-col items-center gap-2 rounded-xl bg-surface p-3 transition-colors', listening && 'bg-[color-mix(in_oklab,var(--primary)_7%,var(--surface))]')}>
            <Wave active={listening} />
            <p className="min-h-5 text-center text-[13px] text-muted-foreground" aria-live="polite">
              {listening && heard
                ? heard.split(' ').map((w, i) => (
                    <span key={i} className="kitm-in inline-block text-foreground [--kitm-t:.3s]">
                      {w}&nbsp;
                    </span>
                  ))
                : listening
                  ? t.listening
                  : t.listen}
            </p>
            <button
              type="button"
              onClick={() => setListening((l) => !l)}
              aria-pressed={listening}
              aria-label={listening ? t.stopListening : t.listen}
              className={cn('relative grid size-14 place-items-center rounded-full shadow-[var(--shadow-md)] transition-[background-color,transform] active:scale-95', listening ? 'bg-destructive text-destructive-foreground' : 'bg-primary text-primary-foreground')}
            >
              {listening && <span className="absolute inset-0 animate-ping rounded-full bg-destructive opacity-30 motion-reduce:hidden" aria-hidden />}
              <Icon name={listening ? 'stop' : 'mic'} size={22} className={listening ? 'fill-current' : ''} />
            </button>
          </div>
          <form
            onSubmit={(e) => {
              e.preventDefault();
              const input = e.currentTarget.elements.namedItem('q') as HTMLInputElement;
              c.send(input.value);
              input.value = '';
            }}
            className="flex items-center gap-1.5"
          >
            <label htmlFor={`${id}-q`} className="sr-only">
              {t.typeInstead}
            </label>
            <input id={`${id}-q`} name="q" autoComplete="off" placeholder={t.typeInstead} className="h-10 min-w-0 flex-1 rounded-lg border border-input bg-background px-3 text-[14px] text-foreground outline-none placeholder:text-subtle-foreground focus:border-border-strong" />
            <button type="submit" aria-label={t.send} className="grid size-10 place-items-center rounded-lg bg-foreground text-background">
              <Icon name="send" size={16} />
            </button>
          </form>
        </div>
      </div>
      {nudge && !open && (
        <div className="kitm-fly relative flex max-w-[16rem] items-center gap-2 rounded-2xl rounded-br-md border border-border bg-popover py-2 pl-3.5 pr-1.5 text-[13.5px] text-foreground shadow-[var(--highlight),var(--shadow-lg)]" role="status">
          <button type="button" onClick={() => setOpen(true)} className="text-left">
            {t.nudge}
          </button>
          <button type="button" onClick={() => setNudge(false)} aria-label={t.dismiss} className="grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-accent hover:text-foreground">
            <Icon name="x" size={14} />
          </button>
        </div>
      )}
      <button
        ref={launcher}
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-controls={id}
        aria-label={open ? t.closeAssistant : t.openAssistant}
        className="group relative grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.2),var(--shadow-lg)] transition-transform hover:scale-105 active:scale-95 motion-reduce:hover:scale-100"
      >
        <Icon key={String(open)} name={open ? 'x' : 'sparkle'} size={22} className="kitm-in transition-transform duration-300 group-hover:rotate-12 [--kitm-t:.3s]" />
      </button>
    </div>
  );
}

/* ------------------------------------------------------------------ */

/**
 * AI surfaces: a chat window, a large prompt composer, agent steps with
 * approvals, chats with history, an answer with sources and a compact voice
 * widget. Replies stream in, Enter sends, Shift Enter adds a line.
 */
export function AiChat({ variant = 'chat-window', labels, ...props }: AiChatProps) {
  const t = useTexts(TEXTS, labels);
  const p: P = { ...props, t };
  const body = (() => {
    switch (variant) {
      case 'prompt-composer':
        return <PromptComposer {...p} />;
      case 'agent-steps':
        return <AgentSteps {...p} />;
      case 'history-sidebar':
        return <HistorySidebar {...p} />;
      case 'answer-with-sources':
        return <AnswerWithSources {...p} />;
      case 'voice-widget':
        return <VoiceWidget {...p} />;
      default:
        return <ChatWindow {...p} />;
    }
  })();
  return (
    <>
      <KitmStyle />
      {body}
    </>
  );
}

Frequent questions

How do I install AI chat?

With aiw add @ai-website/ai-chat. The command puts components/ui/ai-chat.tsx into your project, the code is yours from then on. It brings along button and icon. It needs no further npm packages.

Which variants does AI chat have?

6 variants, Chat window, Prompt composer, Agent steps, With chat history, Answer with sources and Voice widget. You pick one with the variant prop, for example variant="prompt-composer".

Can I translate the texts of AI chat?

Yes. AI chat carries 55 texts of its own in English and German, such as “Message”, “Send” and “Stop”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with AI chat?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI chat with aiw_components and install it with aiw_add @ai-website/ai-chat. Name the variant in your prompt, for example variant="chat-window", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard