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)tool_call/1— the tool-call card, from pending through to its resultchat_sources/1— the RAG sources row under an answercitation/1— an inline numbered citation chipmessage_attachments/1— images and files inside a sent messagequestionnaire/1— structured human-in-the-loop input in the thread
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 }, ... })Answer grounding (RAG citations)
Sources are plain maps — the host app supplies them, this library only renders
them. url is the only key that really matters; title, snippet,
favicon_url and id are optional and degrade gracefully:
sources = [
%{id: "1", url: "https://hexdocs.pm/phoenix_live_view", title: "Phoenix.LiveView",
snippet: "LiveView provides rich, real-time user experiences...",
favicon_url: "https://hexdocs.pm/favicon.ico"},
%{id: "2", url: "https://hexdocs.pm/phoenix", title: "Phoenix"}
]Prompt the model to cite with [^N] footnote markers ("cite your sources
inline as [^1], [^2] matching the numbered context"). Pass sources to
markdown/1 and every complete marker becomes a chip; markers with no matching
source stay as plain text:
<Chat.chat_message role="assistant">
<Chat.markdown content={msg.text} sources={msg.sources} />
<Chat.chat_sources sources={msg.sources} />
</Chat.chat_message>A marker resolves to the source whose id matches N and falls back to the
Nth source in the list, so an id-less list still works positionally.
Only http and https urls are turned into links. A source url with any other
scheme still renders its chip and its row, just without an href — retrieval
output is model-adjacent text, and a javascript: url would otherwise become a
live link (the chips are spliced in after the markdown sanitizer has run).
The streaming path takes the same option — to_html/2 renders the chips into
the HTML you push at a format="markdown" streaming_text/1. Half-arrived
markers ([^ with no closing bracket yet) are left alone, so nothing flashes
broken mid-stream:
socket = push_event(socket, "pc-chat-token", %{
id: "answer",
html: PetalComponents.Chat.to_html(buffer, sources: sources)
})Tool calls
tool_call/1 renders the whole lifecycle a streaming model emits, and the
state machine is your assigns — there is no client state and no hook. Move
the card by patching one value as the stream progresses:
# the model announced the call but the arguments are still arriving
assign(socket, :call, %{state: :input_streaming, name: "web_search"})
# arguments complete, the tool is off doing the work
assign(socket, :call, %{state: :running, name: "web_search", input: args_json})
# it came back
assign(socket, :call, %{state: :complete, name: "web_search",
input: args_json, output: result_json, duration: "1.2s"})
<Chat.tool_call
name={@call.name}
state={@call.state}
icon="web_search"
input={@call[:input]}
output={@call[:output]}
duration={@call[:duration]}
/>See tool_call/1 for the compact burst variant and the error/retry shape.
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.
The sources row under a grounded answer. Collapsed it reads "4 sources" with a
stacked-favicon cluster; open it lists each source with its favicon, title,
domain and snippet. Native <details>, so no JS.
An inline numbered citation chip — the superscript marker that grounds a sentence in a source. Hovering or focusing it reveals a small preview card (title, domain, snippet); activating it opens the source in a new tab.
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.
Attachments rendered inside a sent message — the images and files that went
along with the text. Drop it in a chat_message/1 body, before or after the
prose
The composer. Wraps a form; pass phx-submit (and optionally phx-change)
through the global attrs.
Structured human-in-the-loop input, rendered inside the conversation. The model (via your app) emits a question spec; this renders it as a form in the transcript, and once the app has the answer it renders back as a quiet summary so the transcript stays honest about what was asked and answered.
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, and the whole lifecycle of the call that produced it.
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)
The sources row under a grounded answer. Collapsed it reads "4 sources" with a
stacked-favicon cluster; open it lists each source with its favicon, title,
domain and snippet. Native <details>, so no JS.
<Chat.chat_sources sources={@sources} />
<Chat.chat_sources sources={@sources} expanded max_visible={3} />Sources are deduped by URL before render (the same page cited twice is one row), and a nil or empty list renders nothing at all — no empty shell.
Attributes
sources(:list) (required) - list of source maps: %{id, url, title, snippet, favicon_url}; snippet and favicon_url optional. Deduped by URL before render.expanded(:boolean) - render the list open instead of the collapsed 'N sources' row. Defaults tofalse.max_visible(:integer) - sources shown when expanded before a 'Show all (N)' control reveals the rest. Defaults to5.label(:string) - override the collapsed row label; defaults to '{count} sources' / '1 source'. Defaults tonil.class(:any) - Defaults tonil.- Global attributes are accepted.
An inline numbered citation chip — the superscript marker that grounds a sentence in a source. Hovering or focusing it reveals a small preview card (title, domain, snippet); activating it opens the source in a new tab.
markdown/1 and to_html/2 mint these for you from [^N] markers, so you
rarely call it directly. Reach for it when you are assembling prose yourself:
Phoenix ships with LiveView <Chat.citation index={1} source={@source} />Attributes
index(:integer) (required) - 1-based citation number shown in the chip.source(:map) (required) - the source map this chip points at: %{url, title, snippet, favicon_url}; every key buturlis optional.class(:any) - Defaults tonil.
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.sources(:list) - when set, complete[^N]markers in the content render as inline citation chips for the matching source (byid, falling back to the Nth source). Unmatched markers stay as plain text. 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)
Attachments rendered inside a sent message — the images and files that went
along with the text. Drop it in a chat_message/1 body, before or after the
prose:
<Chat.chat_message role="user">
<Chat.message_attachments attachments={msg.attachments} />
{msg.text}
</Chat.chat_message>Images render as a thumbnail grid (one image goes large, two or more tile), files as compact download rows. A mixed list puts the images first.
Attributes
attachments(:list) (required) - list of maps: %{kind: :image | :file, url, name, size}. :kind picks the rendering, :size is bytes and is formatted for display or omitted when nil. String or atom keys both accepted.class(:any) - Defaults tonil.- Global attributes are accepted.
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.
Attachments
Pass an %Phoenix.LiveView.UploadConfig{} from allow_upload/3 and the
composer grows a paperclip trigger, a chip strip for the pending entries,
drag-onto-the-composer, paste-an-image, and inline upload errors. It is
ordinary LiveView uploads — this component only renders them:
def mount(_, _, socket) do
{:ok, allow_upload(socket, :attachments, accept: ~w(.png .jpg .jpeg .pdf),
max_entries: 4, max_file_size: 5_000_000)}
end
def handle_event("validate", _params, socket), do: {:noreply, socket}
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :attachments, ref)}
end
def handle_event("send", %{"prompt" => text}, socket) do
files = consume_uploaded_entries(socket, :attachments, fn %{path: path}, entry ->
{:ok, store(path, entry)}
end)
{:noreply, send_message(socket, text, files)}
end
<Chat.prompt_input
phx-submit="send"
phx-change="validate"
upload={@uploads.attachments}
on_cancel_upload="cancel-upload"
accept_hint="Images and PDFs up to 5 MB"
/>phx-change is required for uploads to progress — LiveView needs a change
event on the form. With no upload the composer renders exactly as it always
has.
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.upload(:any) - a %Phoenix.LiveView.UploadConfig{} from allow_upload/3. When set the composer renders a paperclip trigger wrapping a visually hidden live_file_input, attachment chips for @upload.entries, becomes a phx-drop-target, and accepts pasted images. Defaults tonil.on_cancel_upload(:string) - event pushed by a chip's remove button, with phx-value-ref set to the entry ref (wire it to cancel_upload/3). Defaults to"cancel-upload".accept_hint(:string) - human-readable hint of accepted types and size (e.g. "Images and PDFs up to 10 MB"), used as the paperclip button's title and accessible description. 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.
Structured human-in-the-loop input, rendered inside the conversation. The model (via your app) emits a question spec; this renders it as a form in the transcript, and once the app has the answer it renders back as a quiet summary so the transcript stays honest about what was asked and answered.
Server-driven end to end — a plain phx-submit, no client state, no client
form engine.
<Chat.chat_message role="assistant">
<Chat.questionnaire spec={@question} resolved={@answers} allow_skip />
</Chat.chat_message>The spec
%{
id: "q-framework",
title: "Which framework are you targeting?",
description: "This picks the generators I'll reach for.",
fields: [
%{id: "framework", type: :single_select, label: "Framework", required: true,
options: [
%{value: "phoenix", label: "Phoenix", description: "Elixir, LiveView"},
%{value: "rails", label: "Rails", description: "Ruby, Hotwire"}
]},
%{id: "features", type: :multi_select, label: "Features",
options: [%{value: "auth", label: "Auth"}, %{value: "billing", label: "Billing"}]},
%{id: "team", type: :text, label: "Team name", placeholder: "Acme"},
%{id: "confidence", type: :scale, label: "How sure are you?",
min_label: "Not sure", max_label: "Certain", required: true}
]
}String and atom keys are both accepted. :single_select renders radio-cards
when any option carries a description, plain radios otherwise — override
per field with style: "cards" or style: "buttons".
The params you get back
Inputs are named answers[<field_id>] (answers[<field_id>][] for
multi-select), plus a hidden spec_id echoing the spec:
def handle_event("questionnaire_submit", %{"spec_id" => id, "answers" => answers}, socket) do
# %{"framework" => "phoenix", "features" => ["auth"], "confidence" => "4"}
{:noreply, socket |> answer(id, answers) |> ask_the_model(answers)}
end
def handle_event("questionnaire_skip", %{"id" => id}, socket) do
{:noreply, assign(socket, :answers, :skipped)}
endRequired fields use the native required attribute — server-side validation
stays in your app. The exception is :multi_select: a native required
checkbox demands that box specifically, so a required multi-select carries
the asterisk and the "(required)" in its legend but is not browser-enforced.
Validate it server-side.
Resolving it
Pass the answer map back as resolved and the form is replaced by chips;
pass :skipped for the skipped line. Nothing focusable is left behind — no
disabled form pretending to still be a control.
Attributes
spec(:map) (required) - the question spec: %{id, title, description, fields: [...]}.idnamespaces the ids inside, so give two questionnaires on one page two ids;titlelabels the form and should be set. Each field is %{id, type, label, required, options, placeholder, min_label, max_label, style}, where type is :single_select | :multi_select | :text | :scale.requiredis browser-enforced everywhere except :multi_select, where it is advisory (marker plus announcement, your server validates). String or atom keys both accepted.resolved(:any) - nil while pending. A map of answers keyed by field id renders the resolved summary; the atom :skipped renders the skipped state. Defaults tonil.on_submit(:string) - phx-submit event name posted to the parent LiveView. Defaults to"questionnaire_submit".allow_skip(:boolean) - renders a Skip button that posts on_skip with the spec id. Defaults tofalse.on_skip(:string) - phx-click event for the skip button, with phx-value-id set to the spec id. Defaults to"questionnaire_skip".submitting(:boolean) - disables every input and both buttons and shows a spinner while the app forwards the answer. Defaults tofalse.submit_label(:string) - text on the submit button. Defaults to"Submit".class(:any) - Defaults tonil.- Global attributes are accepted.
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)})Pass :sources to turn [^N] footnote markers into inline citation chips as
the answer streams (see the "Answer grounding" section in the moduledoc):
PetalComponents.Chat.to_html(buffer, sources: msg.sources)Options
:sources— list of source maps.[^N]markers matching a source render as chips; unmatched and half-streamed markers are left untouched.
A tool-call card — the chrome around a generative-UI widget, and the whole lifecycle of the call that produced it.
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" state={:complete}>
<.weather_card city={@args["city"]} temp={@result.temp} />
</Chat.tool_call>The lifecycle
state is the source of truth and it is entirely server-driven: your
LiveView patches the assign as the model's response streams, and each patch
moves the card. No client state, no hook, no JS.
:pending— the call is announced, the arguments have not arrived. Tool name plus an animated placeholder.:input_streaming— the arguments are arriving token by token. Same placeholder, now labelled as the incoming input.:running— arguments complete, the tool is working. Spinner plus an activity line, andlabelcarries the live status ("Searching the web").:complete— a summary row (check, name,duration) with the input and output below in expandable panels.:error— danger accent, the message inline, and whatever you put in:error_actions(a retry button) beside it. The input panel stays expandable, because the arguments that failed are the useful part.
The three in-progress states carry role="status", so a screen reader
announces the card moving through them; the state itself is also spelled out
in a visually-hidden word next to the tool name, never by colour alone.
<Chat.tool_call name="web_search" state={:running} icon="web_search" label="Searching the web" />
<Chat.tool_call
name="web_search"
state={:complete}
icon="web_search"
duration="1.2s"
input={~s|{"query":"phoenix liveview streams"}|}
output={~s|{"results":3}|}
/>
<Chat.tool_call name="charge_card" state={:error} error="Card token expired before submit.">
<:error_actions>
<button type="button" class="pc-chat__action" phx-click="retry_tool">Retry</button>
</:error_actions>
</Chat.tool_call>input and output take the JSON string you actually have when streaming
function calls. It is pretty-printed server-side and rendered as a code
block; anything that is not valid JSON is shown verbatim rather than
swallowed. For a rendered result — a chart, a map, a form — keep using the
default slot, which is always visible; the panels are for inspecting the
payload, not for hiding your widget.
Compact bursts
An agent that fires six tools in a row should not produce six cards. compact
renders one dense line per call — state glyph, name, duration — and
consecutive rows stack into a tight list. Finished rows are the disclosure
themselves: click (or Enter/Space on the focused row) to reveal the panels.
<Chat.tool_call :for={call <- @calls} compact
name={call.name} state={call.state} icon={call.icon}
duration={call.duration} input={call.input} output={call.output} />Duration
duration is a string you format — the component never ticks a clock. For a
live elapsed time while :running, compose PetalComponents.LocalTime into
the label, or recompute the string on the same timer that drives the state.
Icons
icon takes one of the presets — "web_search", "code", "database" — or
any "hero-*" name, which passes straight through to
PetalComponents.Icon.icon/1. For a vendor logo or anything that is not a
heroicon, use the :tool_icon slot. An unrecognised string renders no icon
rather than raising.
Attributes
name(:string) (required)state(:atom) - lifecycle state, server-driven. Defaults to nil, which falls back to the legacystatusattr — so a card given neither renders exactly as it always has (a completed call). Set this on new code. Defaults tonil. Must be one ofnil,:pending,:input_streaming,:running,:complete, or:error.status(:atom) - DEPRECATED, usestate. Kept so existing call sites render unchanged; consulted only whilestateis nil, and its three values map onto the states of the same name. Defaults to:complete. Must be one of:running,:complete, or:error.label(:string) - human label; defaults to the tool name. Defaults tonil.icon(:string) - a preset ("web_search", "code", "database") or any heroicon name ("hero-*"), shown before the tool name. nil shows the state glyph only, and an unrecognised value renders no icon. Defaults tonil.compact(:boolean) - one dense line per call for multi-tool bursts: state glyph, name, duration. Finished rows expand on click to reveal the panels; consecutive compact calls stack as a list. Defaults tofalse.duration(:string) - elapsed or total time shown in the header, e.g. "1.2s". You format it — the component never ticks a clock. For a live elapsed while :running, composePetalComponents.LocalTimeinto the label. Defaults tonil.input(:string) - tool arguments as a JSON string; pretty-printed into the expandable Input panel, or shown verbatim if it is not valid JSON. Only rendered once the call has settled (:complete or :error). Defaults tonil.output(:string) - tool result as a JSON string; pretty-printed into the expandable Output panel, or shown verbatim if it is not valid JSON. For a rendered widget use the default slot instead. Defaults tonil.error(:string) - error message rendered inline when the state is :error. Defaults tonil.class(:any) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block- the rendered widget / tool result. Always visible, never collapsed. Wrap bare text in an element - the body hides itself when it contains no element, which is what keeps whitespace-only inner content (a formatted call whose only real content is a named slot) from rendering an empty padded strip.tool_icon- custom icon markup (a vendor logo, an emoji), overriding theiconattr. Namedtool_iconrather thaniconbecause a slot cannot share a name with an attr.input_panel- custom Input panel content, overriding theinputattr.output_panel- custom Output panel content, overriding theoutputattr.error_actions- actions rendered beside the error message, e.g. a retry button with phx-click.