PetalComponents.Chat (petal_components v4.15.3)

Copy Markdown View Source

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:

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

action_button(assigns)

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 to nil.
  • Global attributes are accepted.

chat_error(assigns)

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 to nil.
  • retry_label (:string) - Defaults to "Retry".
  • class (:any) - Defaults to nil.

Slots

  • inner_block (required)

chat_message(assigns)

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 to nil.
  • 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)

chat_sources(assigns)

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 to false.
  • max_visible (:integer) - sources shown when expanded before a 'Show all (N)' control reveals the rest. Defaults to 5.
  • label (:string) - override the collapsed row label; defaults to '{count} sources' / '1 source'. Defaults to nil.
  • class (:any) - Defaults to nil.
  • Global attributes are accepted.

citation(assigns)

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 but url is optional.
  • class (:any) - Defaults to nil.

conversation(assigns)

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 to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)
  • footer - pinned below the scroll area, e.g. a prompt_input.

copy_button(assigns)

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 to false.
  • class (:any) - Defaults to nil.

markdown(assigns)

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 to nil.
  • sources (:list) - when set, complete [^N] markers in the content render as inline citation chips for the matching source (by id, falling back to the Nth source). Unmatched markers stay as plain text. Defaults to nil.
  • class (:any) - Defaults to nil.

marker(assigns)

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 to nil.
  • loading (:boolean) - spinner + role=status for in-progress work. Defaults to false.
  • class (:any) - Defaults to nil.
  • Global attributes are accepted.

Slots

  • inner_block (required)

message_actions(assigns)

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 to nil.

Slots

  • inner_block (required)

message_attachments(assigns)

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 to nil.
  • Global attributes are accepted.

prompt_input(assigns)

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 a pc-chat-set-input event (%{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 to false.
  • on_stop (:string) - event pushed when the stop button is clicked while loading. Defaults to nil.
  • submit_label (:string) - text for the send button; the default is the arrow-up icon convention. Defaults to nil.
  • editing (:boolean) - show the edit-mode banner above the field (set while editing a past message). Defaults to false.
  • 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 to nil.
  • 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 to nil.
  • 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 to nil.
  • class (:any) - Defaults to nil.
  • Global attributes are accepted. Supports all globals plus: ["phx-submit", "phx-change", "phx-target"].

Slots

  • actions - extra controls left of the send button.

questionnaire(assigns)

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)}
end

Required 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: [...]}. id namespaces the ids inside, so give two questionnaires on one page two ids; title labels 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. required is 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 to nil.
  • 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 to false.
  • 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 to false.
  • submit_label (:string) - text on the submit button. Defaults to "Submit".
  • class (:any) - Defaults to nil.
  • Global attributes are accepted.

reasoning(assigns)

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 to false.
  • class (:any) - Defaults to nil.

Slots

  • inner_block (required)

rich_text(assigns)

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 to nil.

  • class (:any) - Defaults to nil.

streaming_text(assigns)

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 (see to_html/1). Defaults to "text". Must be one of "text", or "markdown".
  • class (:any) - Defaults to nil.

suggestions(assigns)

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 to nil.

to_html(content, opts \\ [])

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.

tool_call(assigns)

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, and label carries 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 legacy status attr — so a card given neither renders exactly as it always has (a completed call). Set this on new code. Defaults to nil. Must be one of nil, :pending, :input_streaming, :running, :complete, or :error.
  • status (:atom) - DEPRECATED, use state. Kept so existing call sites render unchanged; consulted only while state is 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 to nil.
  • 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 to nil.
  • 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 to false.
  • 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, compose PetalComponents.LocalTime into the label. Defaults to nil.
  • 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 to nil.
  • 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 to nil.
  • error (:string) - error message rendered inline when the state is :error. Defaults to nil.
  • class (:any) - Defaults to nil.
  • 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 the icon attr. Named tool_icon rather than icon because a slot cannot share a name with an attr.
  • input_panel - custom Input panel content, overriding the input attr.
  • output_panel - custom Output panel content, overriding the output attr.
  • error_actions - actions rendered beside the error message, e.g. a retry button with phx-click.