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 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 "Copied!"
feedback. 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
A row of actions under a message (copy, regenerate, feedback). Compose with
copy_button/1 and 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 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.inner_block(required)
A scrollable conversation thread. Composition-first: drop chat_message/1,
streaming_text/1, or your own markup inside.
<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.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 "Copied!"
feedback. Requires a unique id.
Attributes
id(:string) (required)text(:string) (required) - the text to copy.label(:string) - Defaults to"Copy".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.
A row of actions under a message (copy, regenerate, feedback). Compose with
copy_button/1 and your own phx-click buttons using the pc-chat__action
class.
<Chat.message_actions>
<Chat.copy_button id={"copy-#{@id}"} text={@text} />
<button class="pc-chat__action" phx-click="regenerate">Regenerate</button>
</Chat.message_actions>Attributes
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) - 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) - Defaults to"Send".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.