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
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
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 55 eigene Texte
- Studio
- Im Code anpassen
- In Sammlungen
- KI-Produkt
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle AiChatProps aus components/ui/ai-chat.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalAiChatVariantchat-windowprompt-composeragent-stepshistory-sidebaranswer-with-sourcesvoice-widget
assistantPflicht{ name: string; model?: string; avatar?: React.ReactNode }useroptional{ name: string }messagesoptionalChatMessage[]Conversation so far
suggestionsoptional{ label: string; prompt?: string; icon?: IconName }[]Prompts to start with
placeholderoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodemodelsoptionalstring[]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
stepsoptionalAgentStep[]historyoptional{ id: string; title: string; group: string; active?: boolean }[]history-sidebar
questionoptionalstringanswer-with-sources
answeroptional{ text: string; cites?: number[] }[]sourcesoptionalSource[]relatedoptionalstring[]defaultOpenoptionalbooleanvoice-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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| message | Englisch Message | Deutsch Nachricht |
| messageTo | Englisch Message {name} | Deutsch Nachricht an {name} |
| send | Englisch Send | Deutsch Senden |
| stop | Englisch Stop | Deutsch Stoppen |
| attach | Englisch Attach files | Deutsch Dateien anhängen |
| remove | Englisch Remove {name} | Deutsch {name} entfernen |
| model | Englisch Model | Deutsch Modell |
| online | Englisch Online | Deutsch Online |
| thinking | Englisch Thinking | Deutsch Denkt nach |
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| good | Englisch Good answer | Deutsch Gute Antwort |
| bad | Englisch Bad answer | Deutsch Schlechte Antwort |
| retry | Englisch Try again | Deutsch Neu versuchen |
| enterHint | Englisch Enter to send, Shift Enter for a new line | Deutsch Enter sendet, Umschalt Enter macht eine neue Zeile |
| disclaimer | Englisch Answers can be wrong. Check what matters. | Deutsch Antworten können falsch sein. Prüfe, was wichtig ist. |
| webSearch | Englisch Search the web | Deutsch Im Web suchen |
| fallback | Englisch 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? |
| you | Englisch You | Deutsch Du |
| newChat | Englisch New chat | Deutsch Neuer Chat |
| searchChats | Englisch Search chats | Deutsch Chats durchsuchen |
| history | Englisch Chats | Deutsch Chats |
| openHistory | Englisch Show chats | Deutsch Chats zeigen |
| closeHistory | Englisch Close chats | Deutsch Chats schließen |
| noChats | Englisch No chat matches. | Deutsch Kein Chat passt. |
| progress | Englisch {done} of {total} steps | Deutsch {done} von {total} Schritten |
| running | Englisch Working | Deutsch Arbeitet |
| done | Englisch Done | Deutsch Fertig |
| pending | Englisch Waiting | Deutsch Wartet |
| approval | Englisch Needs your approval | Deutsch Braucht deine Freigabe |
| failed | Englisch Failed | Deutsch Fehlgeschlagen |
| approve | Englisch Approve and continue | Deutsch Freigeben und weiter |
| decline | Englisch Skip this step | Deutsch Schritt überspringen |
| showDetails | Englisch Show details | Deutsch Details zeigen |
| hideDetails | Englisch Hide details | Deutsch Details verbergen |
| stopAgent | Englisch Stop agent | Deutsch Agent stoppen |
| stopped | Englisch Stopped. Nothing after this step was changed. | Deutsch Gestoppt. Nach diesem Schritt wurde nichts verändert. |
| finished | Englisch Finished. Everything is ready for review. | Deutsch Fertig. Alles liegt zur Durchsicht bereit. |
| files | Englisch Files | Deutsch Dateien |
| sources | Englisch Sources | Deutsch Quellen |
| answer | Englisch Answer | Deutsch Antwort |
| related | Englisch Related | Deutsch Weiterführend |
| followUp | Englisch Ask a follow-up | Deutsch Nachfrage stellen |
| source | Englisch Source {n} | Deutsch Quelle {n} |
| openAssistant | Englisch Open assistant | Deutsch Assistent öffnen |
| closeAssistant | Englisch Close assistant | Deutsch Assistent schließen |
| listen | Englisch Start speaking | Deutsch Sprechen |
| stopListening | Englisch Stop listening | Deutsch Zuhören beenden |
| listening | Englisch Listening… | Deutsch Hört zu… |
| tapToTalk | Englisch Tap the microphone and talk, or type below. | Deutsch Tippe aufs Mikrofon und sprich, oder schreib unten. |
| typeInstead | Englisch Type a message | Deutsch Nachricht schreiben |
| heard | Englisch Do you have a table for four on Friday evening? | Deutsch Habt ihr am Freitagabend einen Tisch für vier? |
| voiceReply | Englisch 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? |
| nudge | Englisch Questions? Ask by voice or text. | Deutsch Fragen? Frag per Stimme oder Text. |
| dismiss | Englisch Dismiss | Deutsch 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
'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}
</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.