AI chat / conversation components — the LiveView-native answer to React's AI Elements / assistant-ui. Build streaming chat UIs without a client AI SDK: tokens stream over the LiveView socket you already have.
Components:
conversation/1— scrollable thread container (slot-driven)chat_message/1— a single message bubble (user or assistant)streaming_text/1— token-by-token output via thePetalChatStreamJS hookprompt_input/1— the composer (textarea + send)
Importing
Unlike the core components, Chat is not brought in by use PetalComponents
— it defines generic names (markdown/1, reasoning/1, …) that would clash with
your app's own helpers. Alias it and call it namespaced:
alias PetalComponents.Chat
<Chat.conversation id="chat">
<Chat.chat_message role="assistant"><Chat.markdown content={@text} /></Chat.chat_message>
</Chat.conversation>The examples below use that Chat. prefix.
Streaming
streaming_text/1 is driven by the bundled PetalChatStream JS hook. The
parent LiveView pushes each delta and the hook appends it to the bubble:
# per Gemini/OpenAI delta:
socket = push_event(socket, "pc-chat-token", %{id: "answer", text: delta})
<Chat.streaming_text id="answer" />Register the hooks once in your LiveSocket:
import PetalComponents from "../../deps/petal_components/assets/js/petal_components"
new LiveSocket("/live", Socket, { hooks: { ...PetalComponents }, ... })Styling
Every component takes a class that is appended last (CSS specificity wins,
matching the rest of petal_components). Theme tokens are exposed as CSS
variables (--pc-chat-*) for reskinning without touching markup, and any part
can be fully replaced via slots.
Summary
Functions
An icon action for the message bar - thumbs up/down, regenerate, share.
Icon-only with the accessible name on label (also the tooltip). Pass any
phx-* binding through.
An error notice with an optional retry button.
A single message bubble.
A scrollable conversation thread. Composition-first: drop chat_message/1,
streaming_text/1, or your own markup inside.
A copy-to-clipboard button (via the PetalCopy hook). Shows brief feedback -
the text flips to "Copied!", or in icon mode the clipboard swaps to a
check. Requires a unique id.
Renders markdown as sanitized, syntax-highlighted HTML (via MDEx). Use it for committed assistant messages so headings, lists, tables and code blocks render properly
An inline conversation marker - a system note, a status row, or a labelled separator between sections of the thread.
A row of actions under a message - the copy / feedback / regenerate bar.
Compose with copy_button/1, action_button/1, or your own phx-click
buttons using the pc-chat__action class.
The composer. Wraps a form; pass phx-submit (and optionally phx-change)
through the global attrs.
A collapsible "thinking" / reasoning block for reasoning-model output. Native
<details>, so no JS.
Markdown with inline widget directives ("MDX for Phoenix").
Token-by-token streaming output, driven by the PetalChatStream JS hook.
Clickable prompt-starter chips for an empty state. Each pushes on_select
with phx-value-prompt set to the suggestion.
Render markdown to sanitized, syntax-highlighted HTML using the same engine
the markdown/1 component uses. Use it to live-stream markdown: throttle calls
on your growing buffer and push_event the result to a format="markdown"
streaming_text/1
A tool-call card — the chrome around a generative-UI widget.
Functions
An icon action for the message bar - thumbs up/down, regenerate, share.
Icon-only with the accessible name on label (also the tooltip). Pass any
phx-* binding through.
<Chat.action_button icon="hero-hand-thumb-up" label="Good response" phx-click="feedback" phx-value-vote="up" />Attributes
icon(:string) (required) - heroicon name.label(:string) (required) - accessible name, also shown as the tooltip.class(:any) - Defaults tonil.- Global attributes are accepted.
An error notice with an optional retry button.
<Chat.chat_error on_retry="retry">Something went wrong.</Chat.chat_error>Attributes
on_retry(:string) - Defaults tonil.retry_label(:string) - Defaults to"Retry".class(:any) - Defaults tonil.
Slots
inner_block(required)
A single message bubble.
Default markup, or replace it entirely — the class is appended last so your
utilities win, and the :inner_block is yours to fill.
Attributes
role(:string) - Defaults to"assistant". Must be one of"user","assistant", or"system".class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
avatar- optional leading avatar/icon.actions- an action bar rendered below the message, outside the bubble - message_actions/1. Works on any role: copy/edit under a user message, copy/feedback/regenerate under an assistant one.inner_block(required)
A scrollable conversation thread. Composition-first: drop chat_message/1,
streaming_text/1, or your own markup inside.
Opens scrolled to the latest message. When older history is inserted above
(pagination), the reader's position is preserved - give thread rows stable
ids (or render them from a LiveView stream) so patches reuse the DOM
nodes; without ids, LiveView rebuilds the siblings and the browser resets
the scroll.
<Chat.conversation>
<Chat.chat_message :for={msg <- @messages} role={msg.role}>{msg.text}</Chat.chat_message>
<:footer>
<Chat.prompt_input phx-submit="send" loading={@streaming?} />
</:footer>
</Chat.conversation>Attributes
id(:string) - defaults to a generated id so multiple threads can coexist.variant(:string) - plain is the AI convention (ChatGPT/Claude): assistant text sits on the surface, only the user gets a bubble. bubbles puts both sides in bubbles (messenger style). Defaults to"plain". Must be one of"plain", or"bubbles".class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required)footer- pinned below the scroll area, e.g. a prompt_input.
A copy-to-clipboard button (via the PetalCopy hook). Shows brief feedback -
the text flips to "Copied!", or in icon mode the clipboard swaps to a
check. Requires a unique id.
Attributes
id(:string) (required)text(:string) (required) - the text to copy.label(:string) - Defaults to"Copy".icon(:boolean) - icon-only (clipboard -> check feedback). Defaults tofalse.class(:any) - Defaults tonil.
Renders markdown as sanitized, syntax-highlighted HTML (via MDEx). Use it for committed assistant messages so headings, lists, tables and code blocks render properly:
<Chat.chat_message role="assistant"><Chat.markdown content={msg.text} /></Chat.chat_message>Output is sanitized server-side — model text is never rendered as live markup.
Markdown is rendered faithfully
Code blocks come from the model's own fences. If a model wraps an example
that itself contains a ``` fence inside another same-length ``` fence,
that is invalid CommonMark and renders broken (the outer fence closes early) —
every CommonMark renderer behaves this way. Steer the model with a system
prompt: "when showing example markdown that contains code fences, wrap the
outer block in MORE backticks than the inner fence."
Attributes
content(:string) (required)id(:string) - pass a unique id to enable per-code-block copy buttons. Defaults tonil.class(:any) - Defaults tonil.
An inline conversation marker - a system note, a status row, or a labelled separator between sections of the thread.
<Chat.marker icon="hero-magnifying-glass">Searched the web</Chat.marker>
<Chat.marker variant="separator">Today</Chat.marker>
<Chat.marker variant="border" icon="hero-check-circle">Context compacted</Chat.marker>
<Chat.marker loading>Thinking...</Chat.marker>While loading it shows a small spinner and announces as a live status
region. For the shimmering streaming-status treatment, compose the existing
PetalComponents.TextAnimation.shimmer_text/1 inside:
<Chat.marker loading><.shimmer_text>Running the numbers...</.shimmer_text></Chat.marker>Attributes
variant(:string) - inline note, centred labelled separator, or a full-width bordered row. Defaults to"inline". Must be one of"inline","separator", or"border".icon(:string) - heroicon name rendered before the text. Defaults tonil.loading(:boolean) - spinner + role=status for in-progress work. Defaults tofalse.class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required)
A row of actions under a message - the copy / feedback / regenerate bar.
Compose with copy_button/1, action_button/1, or your own phx-click
buttons using the pc-chat__action class.
<Chat.message_actions>
<Chat.copy_button id={"copy-#{@id}"} text={@text} icon />
<Chat.action_button icon="hero-hand-thumb-up" label="Good response" phx-click="feedback" phx-value-vote="up" />
<Chat.action_button icon="hero-arrow-path" label="Regenerate" phx-click="regenerate" />
</Chat.message_actions>visible="hover" fades the bar in when the message row is hovered or
focused (ChatGPT-style density for long threads). Touch devices have no
hover, so there the bar always shows.
Attributes
visible(:string) - hover reveals the bar on message-row hover/focus; always shows on touch. Defaults to"always". Must be one of"always", or"hover".class(:any) - Defaults tonil.
Slots
inner_block(required)
The composer. Wraps a form; pass phx-submit (and optionally phx-change)
through the global attrs.
<Chat.prompt_input phx-submit="send" phx-change="draft" value={@draft} loading={@streaming?} on_stop="stop" />While loading, the input stays editable (so you can draft your next message)
and the send button becomes a stop button that pushes on_stop — wire it to
cancel your generation task.
Attributes
id(:string) - defaults to a generated id so multiple composers can coexist.name(:string) - Defaults to"prompt".value(:string) - initial textarea value. The field is uncontrolled after mount (phx-update=ignore, so keystrokes never re-render and lose focus); set it later - edit, quote, clear - by pushing apc-chat-set-inputevent (%{value: text}, optional%{id: composer_id}) to the PetalChatComposer hook. Defaults to"".placeholder(:string) - Defaults to"Send a message...".aria_label(:string) - accessible label for the textarea. Defaults to"Message".loading(:boolean) - Defaults tofalse.on_stop(:string) - event pushed when the stop button is clicked while loading. Defaults tonil.submit_label(:string) - text for the send button; the default is the arrow-up icon convention. Defaults tonil.editing(:boolean) - show the edit-mode banner above the field (set while editing a past message). Defaults tofalse.edit_label(:string) - label shown in the edit banner. Defaults to"Editing message".on_cancel_edit(:string) - event pushed when the edit banner's cancel (X) is clicked. Defaults tonil.class(:any) - Defaults tonil.- Global attributes are accepted. Supports all globals plus:
["phx-submit", "phx-change", "phx-target"].
Slots
actions- extra controls left of the send button.
A collapsible "thinking" / reasoning block for reasoning-model output. Native
<details>, so no JS.
<Chat.reasoning>Chain of thought here...</Chat.reasoning>
<Chat.reasoning label="Thought for 3s" open>...</Chat.reasoning>Attributes
label(:string) - Defaults to"Reasoning".open(:boolean) - Defaults tofalse.class(:any) - Defaults tonil.
Slots
inner_block(required)
Markdown with inline widget directives ("MDX for Phoenix").
The model can drop a widget mid-prose with a fenced block tagged
```widget:<name> containing JSON args. Everything else renders as normal
markdown (normal code fences like ```elixir are untouched). You supply a
render_widget function that maps a name + args to a rendered component:
<Chat.rich_text
content={@text}
render_widget={fn
"weather", args -> ~H"<.weather_card city={args["city"]} .../>"
_, _ -> nil
end}
/>Example model output:
Here's the forecast:
```widget:weather
{"city": "Paris"}
```
Pack an umbrella.Attributes
content(:string) (required)render_widget(:any) - fn(name :: String.t(), args :: map) -> rendered | nil. Defaults tonil.class(:any) - Defaults tonil.
Token-by-token streaming output, driven by the PetalChatStream JS hook.
Render this in the in-progress assistant bubble. The parent LiveView pushes each delta to it:
socket = push_event(socket, "pc-chat-token", %{id: "answer", text: delta})
<Chat.streaming_text id="answer" />Until the first token lands it shows a typing indicator; on the first token it
swaps to live text with a blinking caret. The element owns its own DOM
(phx-update="ignore"), so no re-render clobbers the streamed text.
Attributes
id(:string) (required)event(:string) - push_event name the hook listens for. Defaults to"pc-chat-token".format(:string) - "text" appends raw token deltas; "markdown" replaces innerHTML with rendered HTML you push (seeto_html/1). Defaults to"text". Must be one of"text", or"markdown".class(:any) - Defaults tonil.
Clickable prompt-starter chips for an empty state. Each pushes on_select
with phx-value-prompt set to the suggestion.
<Chat.suggestions items={["Summarise this", "Write tests"]} on_select="suggest" />Attributes
items(:list) (required)on_select(:string) - event pushed with phx-value-prompt. Defaults to"suggestion".class(:any) - Defaults tonil.
Render markdown to sanitized, syntax-highlighted HTML using the same engine
the markdown/1 component uses. Use it to live-stream markdown: throttle calls
on your growing buffer and push_event the result to a format="markdown"
streaming_text/1:
socket = push_event(socket, "pc-chat-token", %{id: "answer", html: PetalComponents.Chat.to_html(buffer)})
A tool-call card — the chrome around a generative-UI widget.
This is the "AI Elements" pattern done LiveView-native: the model emits a
structured tool call (function calling), you map the tool name to one of your
registered Phoenix components, and render it inside this card. The widget is a
real LiveView component — it can have its own phx-click, forms, streams.
<Chat.tool_call name="get_weather" status={:complete}>
<.weather_card city={@args["city"]} temp={@result.temp} />
</Chat.tool_call>status drives the header affordance: :running shows a spinner, :complete
a check, :error a warning.
Attributes
name(:string) (required)status(:atom) - Defaults to:complete. Must be one of:running,:complete, or:error.label(:string) - human label; defaults to the tool name. Defaults tonil.class(:any) - Defaults tonil.
Slots
inner_block- the rendered widget / tool result.