Skip to content
kitn AI/UI

Chat

kai-chat

A full-featured chat UI — thread, composer, and optional header — packaged as a single Shadow DOM web component for any framework.

  • Shadow DOM
  • 8 events
  • Markdown + syntax highlighting
  • Model switcher
  • Entity pills (/ skills, @ agents)
  • Starter suggestions

Set rich data (arrays, objects) in JavaScript; scalar props work as attributes or properties:

<kai-chat id="chat" style="display:block; height:100vh;"></kai-chat>
<script type="module">
import '@kitn.ai/ui/elements';
await customElements.whenDefined('kai-chat');
const chat = document.getElementById('chat');
chat.messages = [
{ id: '1', role: 'user', parts: [{ type: 'text', text: 'How do I center a div?' }] },
{ id: '2', role: 'assistant', parts: [{ type: 'text', text: 'Use `display: grid; place-items: center;`' }],
actions: ['copy', 'like', 'dislike'] },
];
chat.addEventListener('kai-submit', (e) => {
console.log('user sent:', e.detail.value, 'attachments:', e.detail.attachments);
});
chat.addEventListener('kai-message-action', (e) => {
console.log(e.detail.messageId, e.detail.action);
});
</script>
  • messages — each entry: id, role ('user' | 'assistant'), an ordered parts array (text rendered as markdown for assistant, plus reasoning, tool, card, source, file), and optional actions, avatar, and feedback.
  • loading — flip to true while awaiting a reply; the input disables and a typing indicator appears.

Add actions: ['copy', 'like', 'dislike'] to a message and the action row is wired for you. copy writes to the clipboard and shows a check; like / dislike mark the vote, hide the other, and toggle off on a second tap. Each raises a toast. See Message for the full behavior.

kai-message-action reports votes with a state field — 'on' when set, 'off' when cleared:

chat.addEventListener('kai-message-action', (e) => {
const { messageId, action, state } = e.detail;
if ((action === 'like' || action === 'dislike') && state === 'on') {
saveVote(messageId, action);
}
});

Set feedback: 'like' | 'dislike' on a message to re-hydrate a persisted vote; it wins over the element’s optimistic state.

  • Model switcher — set models ({ id, name, provider? }[]) and currentModel; listen for kai-model-change.
  • Context meter — set context ({ usedTokens, maxTokens, … }) to show a token-usage gauge in the header.

suggestions — clickable prompts above an empty thread; suggestion-mode="fill" populates the input instead of submitting.

models + chat-title activates the header bar with a model-switcher dropdown.

Add context to show a live token-usage gauge — yellow near the warning threshold, red near danger.

Set triggers so / inserts a skill pill and @ opens a sectioned menu of agents and plugins. kai-submit carries the structured doc + entities for your backend to expand.

<kai-chat> is a frame: keep the built-in thread and project your own chrome into named slots. An inject slot adds to a region; a replace slot stands in for it (you own that region’s data and events). See Compose your own shell for a worked example.

SlotModePurpose
injectLeading header controls, left of the title.
injectTrailing header controls.
replaceFull custom header; replaces the built-in title/model/context bar.
injectLeft column (your nav / conversation list). Fixed width; use compose-your-own for resizable.
replaceCustom zero-state rendered in the message area while the thread is empty. Replaces the empty message list only; the composer and any suggestions still render.
replaceFull custom composer; you own submit + loading, drive the thread via messages.
injectAccessory row above the composer.
injectRow below the composer (disclaimers, token meter).

Restyle the built-in regions from outside via ::part — no shadow piercing.

PartPurposeExample
The built-in header bar (the title / model-switcher / context row that hosts the header-start/header-end inject slots). Restyle its height, padding, or gap from outside without replacing the whole header via the `header` slot.
kai-chat::part(header-bar) { height: 3.5rem; padding-inline: 1rem; gap: 0.5rem }
Full custom header; replaces the built-in title/model/context bar.
Left column (your nav / conversation list). Fixed width; use compose-your-own for resizable.
Row below the composer (disclaimers, token meter).
PropertyTypeDefaultNotes
theme'auto'Color mode (`auto` follows prefers-color-scheme).
searchfalseShow a Search (Globe) button in the input toolbar; fires a `search` event.
valueValue of the input. A **string** is controlled (the host owns the text and updates it on `kai-value-change`). A **ComposerDoc** is a one-time seed that pre-populates pills; the user then edits freely. Leave unset for uncontrolled.
placeholder'Send a message...'Placeholder text shown in the empty input.
loadingfalseWhen true, shows the loading/streaming state and disables submit (use while awaiting the assistant's reply).
suggestionsStarter prompts shown above the input when the thread is empty. Clicking one follows `suggestionMode`. Set as a JS property.
suggestionMode'submit'What clicking a suggestion does: `'submit'` (default) sends it immediately as if typed and submitted; `'fill'` just places it in the input.
persistSuggestionsfalseKeep suggestions visible after the conversation starts. By default suggestions are conversation starters and hide once `messages` is non-empty; set this to keep them always shown. Default false.
proseSize'sm'Body/prose font scale for rendered markdown (`'xs' | 'sm' | 'base' | 'lg'`). Defaults to `'sm'`.
codeTheme'github-dark-dimmed'Shiki theme name for syntax-highlighted code blocks (e.g. `'github-dark-dimmed'`).
codeHighlighttrueEnable Shiki syntax highlighting in code blocks. Turn off to render plain `<pre>` blocks (lighter, no highlighter load). Default true.
chatTitleOptional header title shown on the left of the header.
modelsOptional model list. When set (>1 model) a ModelSwitcher is shown in the header and a `kai-model-change` event fires on selection.
currentModelThe currently selected model id (pairs with `models`).
contextOptional context-window token usage. When set, a Context token meter is shown in the header.
scrollButtontrueShow the scroll-to-bottom button inside the scroll area. Default true.
headerStartWhether the host has `slot="header-start"` content (left of the title). Set by the `<kai-chat>` facade so a custom control forces the header open.
headerEndWhether the host has `slot="header-end"` content (right of the controls).
headerFullREPLACE: full custom header in place of the built-in title/model/context bar.
sidebarINJECT: left sidebar column (e.g. a conversation list / your own nav).
emptyREPLACE: custom zero-state rendered in the message area while the thread is empty (replaces the empty message list only; the composer and its suggestions still render).
composerREPLACE: full custom composer in place of the built-in prompt input. The projected content wires its own submit (the data-flow boundary).
composerActionsINJECT: accessory row just above the composer (e.g. extra actions).
footerINJECT: footer row below the composer (disclaimers, token meter, …).
voicefalseShow a Voice (Mic) button in the input toolbar; fires a `voice` event.
triggersRich entity triggers. Each `{ char, kind, items }` opens a caret-anchored menu that inserts an atomic pill (`/` skills, `@` agents/plugins). Set as a JS property; forwarded to the input.
kindIconsDefault icon per entity kind (kind → image src) for pills/menu items.
actionsReveal'always'Whether each message's action bar is always visible (`'always'`, default) or only revealed on hover of that message row (`'hover'`).
messages[]The full message thread to render, newest last. Each entry carries its role, ordered `parts`, and optional actions/avatar/feedback. Set as a JS property (`el.messages = [...]`); a NEW array reference per streaming chunk re-renders (mutating in place does not). Omit for an empty thread. Re-declared here (rather than inherited from `ChatThreadProps`) because the ELEMENT registers a `[]` default and renders the empty state without it, while the SolidJS `<ChatThread>` component still requires it. The facade hands it a validated array either way. Matches `<kai-thread>`.
cardTypesOptional card type -> custom-element tag overrides/additions for `card` parts (merged over the built-ins). Property: `el.cardTypes`. Typed as a plain string map (not the `CardTagMap` alias) so the generated React wrapper inlines it instead of emitting an unresolved named type.
cardSchemasJSON Schemas for the card types this app renders, keyed by envelope type. The companion of `cardTypes`, which says what DRAWS a card while this says what a VALID one looks like. An OBJECT, so it is a JS property only: `el.cardSchemas = { 'pricing-table': pricingSchema }`, never an attribute. `createCardRegistry(...).validationSchemas` is exactly this shape. Without it the kit validates its own seven built-ins and leaves your own card type, the one your app actually cares about, as the only unchecked thing on screen. A schema here WINS over a built-in of the same name. Typed `Record<string, object>` rather than `Record<string, JsonSchema>` deliberately: an imported `.json` schema widens `"type"` to `string`, and an authored one carries `$schema`/`title`/`description`/`additionalProperties`, so the tighter type would reject both of the normal ways to supply one.
EventDetailNotes
The staged attachments changed (file added or removed). Carries the full current list so a consumer can react in real time.
An action button on a message was clicked. `action` is the built-in name or custom id. `state` is present only for the toggleable feedback votes: `'on'` when a like/dislike is set, `'off'` when re-tapped to clear.
The header model switcher changed.
Record<string, never> The Search button was clicked.
User submitted a message.
A suggestion chip was clicked (only in `suggestion-mode="fill"`).
Fired on every input change.
Record<string, never> The Mic / voice button was clicked.
MethodSignatureNotes
(options?: FocusOptions): voidFocus the composer, meaning the contenteditable (or textarea) inside the shadow root. A native `focus()` on the host lands on the host itself and never reaches it, so this is the only way to focus the input programmatically.
(): voidBlur whatever currently holds focus inside the shadow root. The companion to `focus()`, for the same reason: a native `blur()` on the host misses the real focus target.
(): voidEmpty the COMPOSER: drops the draft text and every staged attachment, then fires `kai-value-change` with `''`. It does NOT touch the thread. `messages` is the consumer's own state, so clearing history stays the consumer's call.
(): voidSubmit whatever the composer currently holds, on the same path as Enter or the send button: fires `kai-submit` with that value plus the staged attachments, then drops the attachments. It takes no argument, so to send text the user never typed, set `el.value` first. There is no empty-check, so an empty composer still fires. The draft is cleared afterwards only when `value` is uncontrolled; a controlled host owns its value and clears it itself. Named `send`, not `submit`, to match the shared vocabulary.
(behavior?: ScrollBehavior): voidScroll the message viewport to the newest message. Defaults to `'smooth'`; pass `'instant'` to jump without animating.

This element wraps these SolidJS components — reach for them directly when you need finer control than the props expose.

ChatThread