Zum Inhalt springen
UI Kit
Accent

KI-Chat Component für React

Chats, Eingaben, Agenten und Antworten mit Quellen. Antworten laufen ein, alles geht mit der Tastatur.

Eine von 3 Components in AI und Chat

6 Variants

Das Gespräch, vom Fenster bis zu den Quellen

Ein KI-Chat ist ein Gespräch, das lesbar bleiben muss, während Antworten einlaufen. Diese Component rendert Nachrichten in einem Log, dem Screenreader folgen, hält die Scrollposition ruhig, während Text ankommt, und lässt eine laufende Antwort stoppen sowie jede Antwort kopieren oder neu erzeugen.

Das Chatfenster ist der vollständige Chat mit Kopf, Nachrichten und Eingabe. „Eingabe mit Vorschlägen“ ist die Eingabe allein, für eine Seite, die mit einer Frage beginnt. „Agenten-Schritte“ zeigt Schritt für Schritt, was ein Assistent tut, während er arbeitet. „Mit Verlauf“ ergänzt eine Liste früherer Gespräche, „Antwort mit Quellen“ setzt nummerierte Quellen neben die Aussagen, das Sprach-Widget ist der schwebende Knopf für gesprochene Fragen.

Kennzeichne Antworten als KI-generiert und zeig, woher sie stammen. Halte die Eingabe jederzeit erreichbar, auch am Handy mit offener Tastatur. Die Component zeichnet die Oberfläche, jedes Modell und jede API bindest du über ihre Props an, vom gehosteten Dienst bis zum eigenen Server.

Variants

Chatfenster

Ein vollständiges Gespräch mit einlaufenden Antworten, Aktionen je Nachricht und Schnellantworten.

Eingabe mit Vorschlägen

Eine große Eingabe mit Modell, Websuche, Anhängen und Vorschlagskarten.

Agenten-Schritte

Eine Aufgabe in Schritten mit Werkzeugen, Live-Fortschritt, aufklappbaren Details und einer Freigabe, bevor etwas rausgeht.

Mit Verlauf

Chats nach Tagen mit Suche und neuem Chat. Telefone öffnen die Liste als Panel.

Antwort mit Quellen

Erst die Quellen, dann eine Antwort, deren Belege ihre Quelle aufleuchten lassen. Danach weiterführende Fragen.

Sprach-Widget

Ein kompakter Assistent in der Ecke mit Mikrofon, Live-Wellenform, Schnellantworten und Text als Ausweg.

Details

Was KI-Chat mitbringt, aus Registry und Quellcode gelesen.

Kategorie
AI und Chat
Bereich
App
Variants
6
Version
1.0.1 · seit 1.0.0
Datei
components/ui/ai-chat.tsx
Bringt mit
utilskitbuttonicon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 55 eigene Texte
Studio
Im Code anpassen
In Sammlungen
KI-Produkt

Props

Die Schnittstelle AiChatProps aus components/ui/ai-chat.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    AiChatVariant

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

  • assistantPflicht
    { 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

Eingebaute Texte

Diese Texte schreibt KI-Chat selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 55 Texte anzeigen
messageEnglisch MessageDeutsch Nachricht
messageToEnglisch Message {name}Deutsch Nachricht an {name}
sendEnglisch SendDeutsch Senden
stopEnglisch StopDeutsch Stoppen
attachEnglisch Attach filesDeutsch Dateien anhängen
removeEnglisch Remove {name}Deutsch {name} entfernen
modelEnglisch ModelDeutsch Modell
onlineEnglisch OnlineDeutsch Online
thinkingEnglisch ThinkingDeutsch Denkt nach
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
goodEnglisch Good answerDeutsch Gute Antwort
badEnglisch Bad answerDeutsch Schlechte Antwort
retryEnglisch Try againDeutsch Neu versuchen
enterHintEnglisch Enter to send, Shift Enter for a new lineDeutsch Enter sendet, Umschalt Enter macht eine neue Zeile
disclaimerEnglisch Answers can be wrong. Check what matters.Deutsch Antworten können falsch sein. Prüfe, was wichtig ist.
webSearchEnglisch Search the webDeutsch Im Web suchen
fallbackEnglisch 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?Deutsch 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?
youEnglisch YouDeutsch Du
newChatEnglisch New chatDeutsch Neuer Chat
searchChatsEnglisch Search chatsDeutsch Chats durchsuchen
historyEnglisch ChatsDeutsch Chats
openHistoryEnglisch Show chatsDeutsch Chats zeigen
closeHistoryEnglisch Close chatsDeutsch Chats schließen
noChatsEnglisch No chat matches.Deutsch Kein Chat passt.
progressEnglisch {done} of {total} stepsDeutsch {done} von {total} Schritten
runningEnglisch WorkingDeutsch Arbeitet
doneEnglisch DoneDeutsch Fertig
pendingEnglisch WaitingDeutsch Wartet
approvalEnglisch Needs your approvalDeutsch Braucht deine Freigabe
failedEnglisch FailedDeutsch Fehlgeschlagen
approveEnglisch Approve and continueDeutsch Freigeben und weiter
declineEnglisch Skip this stepDeutsch Schritt überspringen
showDetailsEnglisch Show detailsDeutsch Details zeigen
hideDetailsEnglisch Hide detailsDeutsch Details verbergen
stopAgentEnglisch Stop agentDeutsch Agent stoppen
stoppedEnglisch Stopped. Nothing after this step was changed.Deutsch Gestoppt. Nach diesem Schritt wurde nichts verändert.
finishedEnglisch Finished. Everything is ready for review.Deutsch Fertig. Alles liegt zur Durchsicht bereit.
filesEnglisch FilesDeutsch Dateien
sourcesEnglisch SourcesDeutsch Quellen
answerEnglisch AnswerDeutsch Antwort
relatedEnglisch RelatedDeutsch Weiterführend
followUpEnglisch Ask a follow-upDeutsch Nachfrage stellen
sourceEnglisch Source {n}Deutsch Quelle {n}
openAssistantEnglisch Open assistantDeutsch Assistent öffnen
closeAssistantEnglisch Close assistantDeutsch Assistent schließen
listenEnglisch Start speakingDeutsch Sprechen
stopListeningEnglisch Stop listeningDeutsch Zuhören beenden
listeningEnglisch Listening…Deutsch Hört zu…
tapToTalkEnglisch Tap the microphone and talk, or type below.Deutsch Tippe aufs Mikrofon und sprich, oder schreib unten.
typeInsteadEnglisch Type a messageDeutsch Nachricht schreiben
heardEnglisch Do you have a table for four on Friday evening?Deutsch Habt ihr am Freitagabend einen Tisch für vier?
voiceReplyEnglisch Yes, Friday at 7:30 pm is free for four people. Shall I book it under your name?Deutsch Ja, Freitag um 19:30 Uhr ist für vier Personen frei. Soll ich auf deinen Namen reservieren?
nudgeEnglisch Questions? Ask by voice or text.Deutsch Fragen? Frag per Stimme oder Text.
dismissEnglisch DismissDeutsch Ausblenden

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • KI-Chat reagiert selbst auf die Tasten Escape, Enter und Leertaste.
  • Baut auf dem nativen dialog-Element auf. Der Browser hält den Fokus im Fenster und schließt es mit Escape.
  • Setzt aria-controls, aria-current, aria-describedby, aria-expanded, aria-label, aria-labelledby, aria-live, aria-pressed, aria-relevant, aria-valuemax, aria-valuemin und aria-valuenow, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen dialog, log, progressbar und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 14 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 1.474 Zeilen
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}
    </>
  );
}

Häufige Fragen

Wie installiere ich KI-Chat?

Mit aiw add @ai-website/ai-chat. Der Befehl legt die Datei components/ui/ai-chat.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button und icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat KI-Chat?

6 Varianten, Chatfenster, Eingabe mit Vorschlägen, Agenten-Schritte, Mit Verlauf, Antwort mit Quellen und Sprach-Widget. Du wählst sie mit der Prop variant, zum Beispiel variant="prompt-composer".

Kann ich die Texte von KI-Chat übersetzen?

Ja. KI-Chat bringt 55 eigene Texte auf Deutsch und Englisch mit, etwa „Nachricht“, „Senden“ und „Stoppen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent KI-Chat einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini KI-Chat mit aiw_components und installieren sie mit aiw_add @ai-website/ai-chat. Nenn im Prompt die Variante, etwa variant="chat-window", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert