defmodule AgentixComponents do
@moduledoc """
Default function components for rendering an `Agentix.Chat` conversation.
Optional sugar over the headless projection (`Agentix.Chat`): `import
AgentixComponents` and render the assigns, or run `mix agentix.gen.components` to
copy an editable version into your project. `message/1` exposes a `:bubble` slot;
the pending controls switch on `pending[id].kind` (not the executor).
## Styling
Tailwind utility classes (stock `neutral` scale + `emerald`/`red`/`amber` semantics,
`darkMode: 'class'`) in a flat, borderless style — full-width turn rows on hairline
dividers. Two small extras the host opts into:
* **grouping** — give the thread the `agentix-thread` class to collapse each turn
(a user message, or an assistant message with its tool calls) into one block with
a single header (the CSS lives in `AgentixComponents.css/0`);
* **JS hooks** — `AgentixStream` (streaming text) and `AgentixComposer`
(auto-grow + Enter-to-send), shipped at `priv/static/agentix_stream_hook.js`.
Interactive controls emit `phx-click`/`phx-submit` events for the host to wire to
`Agentix.Chat`: `"send"` (composer), `"approve"`/`"deny"` (`phx-value-id`), and
`"resolve"` (a form carrying `tool_call_id` + `answer`/`result`).
"""
use Phoenix.Component
@doc """
Renders the conversation: a grouped thread of finalized messages, the in-progress
assistant turn (running tools + streaming text), and pending controls. `messages`
accepts a `Phoenix.LiveView` stream or a list of `{dom_id, %ReqLLM.Message{}}` pairs.
"""
attr(:id, :string, default: "agentix-messages")
attr(:messages, :any, default: [])
attr(:streaming_message, :map, default: nil)
attr(:in_flight_tools, :map, default: %{})
attr(:pending, :map, default: %{})
attr(:assistant_open, :boolean,
default: false,
doc:
"true once the current assistant turn has shown a header; continuation rows then render headerless"
)
def message_list(assigns) do
~H"""
<.streaming_message :if={@streaming_message} message={@streaming_message} />
<.assistant_turn :for={{id, entry} <- @pending} open={@assistant_open}>
<.pending id={id} entry={entry} />
"""
end
# An assistant continuation row: shows the avatar + header only when the turn has
# not opened one yet (`open` false); otherwise it's a headerless continuation that
# merges with the assistant block above — so a turn never repeats the header.
attr(:open, :boolean, required: true)
slot(:inner_block, required: true)
defp assistant_turn(assigns) do
~H"""
"""
end
@doc """
Renders one finalized message as a flat row. A `:bubble` slot replaces the default
body. The `data-group` attribute (`"user"` for user messages, `"agent"` for assistant
*and* tool messages) drives turn grouping: every assistant + tool row of one turn
collapses under a single header (see `css/0`). Tool messages render as a headerless
card, never their own "Tool" header — they are part of the assistant's turn.
"""
attr(:id, :string, default: nil)
attr(:message, :map, required: true)
slot(:bubble)
def message(%{message: %{role: :tool}} = assigns) do
~H"""
"-text"}
phx-hook={markdown_hook(@id)}
data-md={markdown_hook(@id) && message_text(@message)}
class={[
"text-[15px] leading-relaxed text-neutral-700 dark:text-neutral-200",
# Markdown renders to HTML (the block tags own their spacing); plain text keeps
# newlines via `whitespace-pre-wrap`. The two are mutually exclusive — applying
# both makes the inter-block newlines marked emits show as blank lines.
if(markdown_hook(@id), do: "agentix-md", else: "whitespace-pre-wrap")
]}
>{message_text(@message)}
{render_slot(@bubble, @message)}
"""
end
@doc "The element the JS streaming hook writes into (text + thinking child nodes)."
attr(:message, :map, required: true)
def streaming_message(assigns) do
~H"""
"""
end
@doc "A collapsed reasoning panel for a finalized turn's thinking."
attr(:label, :string, default: "Reasoning")
slot(:inner_block, required: true)
def reasoning(assigns) do
~H"""
{@label}
{render_slot(@inner_block)}
"""
end
@doc """
A tool call row. `status` is `:running` | `:ok` | `:error`; `meta` (a short progress
label) and `result` (the full result, shown in an expandable inspector) are optional.
"""
attr(:id, :string, required: true)
attr(:name, :string, required: true)
attr(:status, :atom, default: :running)
attr(:meta, :string, default: nil)
attr(:result, :string, default: nil)
def tool(assigns) do
~H"""
"""
end
@doc "Renders the pending affordance for a tool call, switching on its `kind`."
attr(:id, :string, required: true)
attr(:entry, :map, required: true)
def pending(%{entry: %{kind: :approval}} = assigns) do
~H"""