AI Chat Interface

Pro

4 premium chat variants: fullscreen assistant, floating widget, embedded panel and compact sidebar — with streaming indicators, tool/function-call cards, file attachments, copyable code blocks and markdown-ready messages.

</>sv-ai-chat aichatassistanttools
import { SvAiChatComponent } from '@styloviz-pro/ai-chat';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Wired to a streaming API

Pro

Switch layout variant

Pro

Demo with no backend

Pro

When to use

  • Adding a conversational AI surface: assistant, support bot, copilot panel.
  • You want streaming indicators, tool-call chips and markdown/code rendering ready-made.
  • Pick the variant that fits placement — floating for apps, embedded for a page section.

Async states

  • loading
  • error
  • retry
  • offline

Inputs

NameTypeDefaultDescription
variantAiChatVariant'copilot'Chat layout: fullscreen, floating, embedded or sidebar/copilot.
branchingbooleanfalseKeep every regeneration as a version instead of replacing the answer. OFF BY DEFAULT BECAUSE IT CHANGES WHAT REGENERATE DOES. With it off, `regenerate()` deletes the last assistant message and asks for another — which is what this component has always done and what existing hosts have built around. With it on, the old answer survives as a sibling and the row grows a "2 / 3" switcher. The reason to turn it on: a user presses regenerate when they are unsatisfied, and "unsatisfied" is not "this is worse than whatever comes next". The destructive version throws away a good answer with no undo and no warning, routinely. See `ai-chat.branching.ts`.
modelsreadonly AiModel[][]Models offered by the picker.
suggestedPromptsreadonly SuggestedPrompt[][]Starter prompts shown on an empty thread.
sessionsreadonly ChatSession[][]Past conversations, for the fullscreen variant's rail.
agentsreadonly ChatAgent[][]The agents that can speak in this thread. Empty — the default — is a single-model chat, which is what this component has always been: messages render under one sparkle avatar and nothing changes. Supply a roster and every assistant message that names an `agentId` is attributed to it by name.
handoffsreadonly ChatHandoff[][]Control moving between agents. THEIR OWN ENTRIES, NOT INFERRED FROM `agentId` CHANGING. A handoff can produce no reply at all — the specialist has nothing to add and hands straight back — and inference shows nothing at all in that case: the user sees an unexplained pause and the trail of who was consulted is gone.
demoModebooleanfalseFake an assistant reply, token by token, with no backend. For demos and screenshots only. This used to default to `true`, so a chat wired to a real API answered itself as well.
busybooleanfalseA reply is in flight. Ignored while `simulate` is on, which drives its own.
loadingbooleanfalseTrue while the session list or a transcript is being fetched. Shows a placeholder in place of the message list.
connectionSvConnectionState'idle'State of your streaming transport. `isStreaming` says a reply is in progress; this says whether the pipe carrying it is still up. A reply that stops mid-sentence is the single most confusing failure in a chat UI — without this the user cannot tell it from the model pausing to think.
reconnectablebooleanfalseOffer a manual reconnect while the transport is down.
errorSvDataError | string | nullnullFailure loading the conversation. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. A failed *message* is different: set that message's `status` to `'error'` and the row offers its own resend.
messagesChatMessage[] (two-way)[]The transcript. Two-way — append the model's reply, and mutate the tail message's `content` to stream it in.
activeSessionIdstring | null (two-way)nullId of the conversation highlighted in the fullscreen rail and titled in the header. Two-way: picking a session in the rail writes its id and clears the transcript so the host can load it.
maxMessagesnumber300How many messages the transcript holds at once. `0` = no ceiling. A TRANSCRIPT IS THE LIST-SHAPED SURFACE MOST LIKELY TO GET LONG, and an agent conversation with tool calls woven in reaches four figures inside an afternoon. Rendering every one of them is a page that slows down until the user reloads it — and reloading a chat is how a support transcript gets lost. THE WINDOW KEEPS THE NEWEST, which is the only correct direction and the opposite of `slice(0, n)`: a conversation cut at its first hundred messages hides the part the user is having right now. Set `0` if the host virtualizes the thread itself.
stickToBottomThresholdnumber48How close to the bottom of the transcript, in pixels, still counts as "following the conversation". APPENDING MUST NOT FIGHT A READER WHO HAS SCROLLED UP. A new message or a streamed token scrolls the transcript only when the reader was already within this distance of the bottom — or when they just sent a message themselves. Anyone further up keeps their place, and a "Jump to latest" control appears instead of the view being yanked out from under the paragraph they were reading. A few lines of slack, not zero: sub-pixel layout and a token that wraps onto a new line both leave a reader who IS following a few pixels short. Negative or non-finite values are treated as `0`.
selectedModelAiModel | null (two-way)nullThe chosen model. Two-way, so a host can drive it from its own state.

Outputs

EventPayload typeDescription
messageSentstringEmitted with the trimmed text whenever the user sends a message.
modelChangeAiModelEmitted with the newly selected model.
stopRequestvoidThe user asked to stop the reply in progress. Abort your request here — the component has already marked the streaming message complete.
resendRequestChatMessageThe user asked to resend a message that failed. The payload is the original text; the message is back in `sending` state.
retryvoidThe host should re-run the failed conversation load.
branchChangeChatMessageThe user moved to a different version of a message. The payload is the message now shown at that point in the thread — which is also the root of the subtree that came with it, since switching a version switches everything after it.
reconnectvoidThe user asked to re-open the transport.
copyFailedstringCopying a message to the clipboard failed — the payload is that message's id. The clipboard is unavailable over plain HTTP and `writeText` rejects when the permission is denied, so the button stays silent rather than claiming a copy that did not happen. Surface the text some other way.
messagesChangeChatMessage[]Emits the new `messages` whenever it changes — the output half of `[(messages)]`.
activeSessionIdChangestring | nullEmits the new `activeSessionId` whenever it changes — the output half of `[(activeSessionId)]`.
selectedModelChangeAiModel | nullEmits the new `selectedModel` whenever it changes — the output half of `[(selectedModel)]`.

Wire it to your API

This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.

Template

Pro

Bound to the names this component actually exposes.

Host class

Pro

Drive the three async inputs from your own fetch.

  • This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `messages`, `loading` and `error`.
  • `connection` reports the state of your `EventSource` or WebSocket. The component cannot infer it: a list that stops growing looks identical whether the feed is quiet or the socket died.
  • Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.

Full walkthrough: Connecting to a backend.

Customization & theming

  • Every content set is an input and every one defaults to empty — `messages` (two-way), `models`, `selectedModel` (two-way, nullable), `suggestedPrompts`, `sessions` and `activeSessionId` (two-way, nullable). The component ships no sample data; import `DEMO_MODELS` / `DEMO_PROMPTS` / `DEMO_SESSIONS` for a demo.
  • Stream a real reply with `beginAssistantMessage()` → `appendToken()` → `completeAssistantMessage()` / `failAssistantMessage()`. A failed stream keeps whatever text arrived and offers a resend. Acknowledge a delivered user message with `acknowledgeMessage()`, or reveal its resend control with `failMessage()`.
  • While a reply is streaming the send button becomes a stop control, which emits `stopRequest` — abort your request there.
  • Set `demoMode` to fake replies with no backend; it used to be on by default under the name `simulate`.
  • Use `loading` / `error` / `retry` for fetching the transcript or the session list; `busy` for your own in-flight state.
  • Four layouts, set with `variant`: `copilot` (docked side panel), `fullscreen`, `inline` (embedded in a page) and `bubble` (floating launcher).
  • Streaming indicators; assistant messages render `toolCalls` (running/success/error cards with args + result) and `attachments` (file chips).
  • Markdown + code-block rendering with copy.
  • Every variant exposes working per-message copy, like and dislike actions (like/dislike are mutually exclusive) via a shared action row. Copy confirms only once the clipboard write resolves — the clipboard is unavailable over plain HTTP and can reject — and emits `copyFailed` with the message id when it does not, so a host can offer the text another way.
  • Emits `messageSent` (the sent text) and `modelChange` so a host can wire it to a real backend.
  • Responsive: in the fullscreen layout the session sidebar is hidden on phones (chat takes the full width) and returns from `md` up.
  • One privacy policy for the whole pack (`X-2`): `provideSvAiPrivacy({ patterns, reveal, copy, telemetry })` in `@styloviz/core` configures masking once instead of eight times. Message content is masked at RENDER — your `messages` are never edited — and `copy: false` hides the copy control AND refuses `copyMessage()`, because a hidden button is a styling decision and the method is public API. With no policy provided nothing is masked and copy works exactly as before.
  • A scale ceiling on the transcript: `[maxMessages]` (300) is how many messages the thread renders at once, and the window keeps the NEWEST — a conversation cut at its first hundred messages would hide the part the user is having right now. What is missing is disclosed at the top of the thread, where it was, and `messages` itself stays complete: the ceiling is a rendering decision, not an edit to your data. Set `0` if you virtualize the thread yourself.
  • Attachment limits are a policy the kit owns and the host enforces: `svCheckAttachments(list, { maxCount, maxBytes, accept })` returns what is accepted and what is refused, each refusal carrying a reason in `ChatAttachment.rejected`. The count limit **refuses the overflow rather than truncating it**, and a refused attachment stays in the transcript, struck through with its reason — one that disappears is indistinguishable from one that uploaded, and the user sends 'here’s the recording' to a model that never saw it.
  • An unknown size or MIME type is accepted rather than refused: the host may not have either until the upload finishes, and a limit that rejects everything is indistinguishable from a broken picker. A non-positive `maxCount` or `maxBytes` falls back to the default (10 files, 25 MB) rather than meaning 'nothing allowed'.
  • Attachments render on **user** messages as well as assistant ones. They used to render only under an assistant message, so a file the user attached — and any refusal of it — was invisible on the message that carried it.
  • When the clipboard refuses — plain HTTP, a denied permission, an unfocused document — the component **selects the message text** and says which keys to press, naming ⌘C or Ctrl+C for the platform the reader is actually on. Telling somebody 'copying failed' leaves them to select three paragraphs by hand, which is the moment they give up and retype it.
  • Streaming is a **separate package**: `@styloviz-pro/ai-chat-transport`. Nothing in this component imports it, and that is the point — a transport baked into the UI is a UI that has picked your backend for you, and a provider renaming a field would ship as a new version of the chat. It carries `svSseReader` (SSE framing that survives a chunk boundary, which is the bug that makes a hand-rolled loop drop one token in a few hundred), `svFrameGate` (duplicate, late and missing WebSocket frames), `svDecodePart` (an AI-SDK-style part decoder that tells an incremental delta from a cumulative snapshot — confuse them and the reply either repeats itself or loses all but its last token), and `svStreamStart` / `svApplyParts` / `svStreamEnd` / `svToChatMessage` to reduce the lot into one of these messages.
  • A stream that stops arriving without a terminal event is `interrupted`, not `complete`, and `svToChatMessage` renders it as an `error` message so the reader gets the reason and a resend control beside the half answer. Flipping it to complete — tempting, because there IS text to show — tells them the half answer is the whole answer, which is the one lie in a chat UI they will act on.
  • A part of a kind the decoder does not model is counted, not dropped: `svStreamNotice` then says that something which arrived is not shown. Providers add part types constantly, and ignoring the unknown ones produces a transcript missing content that looks whole.
  • The AI SDK is decoded in both of its wire formats: the **v5 UI message stream** (text, reasoning, tool input/output, sources, errors; `start` / `start-step` / `text-start` lifecycle markers are understood rather than counted as unknown; `finish-step` separates the steps of a tool → answer run with a paragraph break and only `finish` ends the reply; `abort` ends it as interrupted) and the **v4 data stream protocol** (`0:` text, `9:` / `a:` tool call and result, `e:` / `d:` step and finish, `3:` error), which arrives as a plain line-per-part body and is read with `svDataStreamReader`. **OpenAI Responses** function calls are assembled from `output_item.added` and `function_call_arguments.delta` addressed by `output_index`.
  • The transport also decodes the two provider formats most hosts actually hold: **Anthropic Messages streaming** (`content_block_delta` text and thinking deltas, `tool_use` arguments assembled from `input_json_delta` fragments addressed by `index`, `message_delta` stop reasons, `ping` ignored) and **OpenAI Chat Completions** (`chat.completion.chunk` — `choices[].delta.content`, index-addressed `tool_calls`, `finish_reason`, and the usage chunk that arrives after the finish). `svFinishReason` maps both vocabularies onto one, so an Anthropic reply cut off at `max_tokens` reads as truncated rather than finished. Use `svDecodeParts` — one Chat Completions chunk can carry several parts, and `svDecodePart` returns such a chunk as `unknown` rather than keeping only the first.
  • Auto-scroll follows the conversation only while the reader is at the bottom (within `stickToBottomThreshold`, default 48px) or has just sent a message. Scrolled up to re-read an answer, their position is kept while tokens stream in, and a **Jump to latest** control — with a count of new messages — appears instead of the view being pulled out from under them.

Best practices

  • Do: Leave `[maxMessages]` on unless you virtualize the thread yourself. An agent conversation with tool calls woven in reaches four figures inside an afternoon, and a thread that renders all of them slows down until the user reloads — which on a chat is how a support transcript gets lost.
  • Do: Use the `variant` that matches placement; wire it to your own message store.
  • Do: Stream responses into a live region so screen readers announce updates (handled).
  • Tip: Treat the component as a styled shell — connect it to your model/transport layer.
  • Do: Acknowledge each user message once the server has it. Until you do it shows "Sending…", which is what makes a dropped message visible instead of silent.
  • Don't: Do not ship `demoMode` enabled. It fabricates assistant replies, so the chat answers itself alongside your real backend.

Accessibility

  • Message log uses a live region for streaming; user text is bound safely and all assistant HTML — including tool-call and attachment markup — is escaped.
  • Model picker exposes aria-haspopup/aria-expanded; feedback buttons report aria-pressed.
  • Input and actions are labelled and keyboard reachable.
  • The Jump to latest control is a real button outside the message live region, so Tab reaches it and it is not announced as a message; activating it moves focus to the transcript rather than dropping it to the page.

Feedback on AI Chat Interface

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.