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"""
<.message :for={{dom_id, message} <- @messages} id={dom_id} message={message} />
<.assistant_turn :if={@streaming_message || @in_flight_tools != %{}} open={@assistant_open}>
<.tool :for={{id, t} <- @in_flight_tools} id={id} name={t.name} status={Map.get(t, :status, :running)} meta={tool_meta_label(t)} />
<.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"""
<.avatar :if={!@open} role={:assistant} />
<.role_header :if={!@open} role={:assistant} /> {render_slot(@inner_block)}
""" 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"""
<.tool id={@message.tool_call_id} name={tool_name(@message)} status={tool_status(@message)} result={message_text(@message)} />
""" end def message(assigns) do ~H"""
<.avatar role={@message.role} />
<.role_header role={@message.role} />
"-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"""
<.tool_icon status={@status} /> {@name} {@meta} {tool_label(@status)}
{if @status == :error, do: "Error", else: "Result"}
{@result}
""" 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"""
<.icon name={:warning} class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-500" />
Permission required
{prompt_label(@entry)}
""" end def pending(assigns) do ~H"""
""" end @doc "An inline error/warning banner." attr(:variant, :atom, default: :error) attr(:title, :string, required: true) slot(:inner_block) def error(assigns) do ~H"""
<.icon name={banner_icon(@variant)} class={["mt-0.5 h-4 w-4 shrink-0", banner_icon_color(@variant)]} />
{@title}
{render_slot(@inner_block)}
""" end @doc """ The message composer: an auto-growing textarea with a send/stop control. Emits `phx-submit="send"` (text field `text`); when `streaming?` it shows a Stop button (`phx-click="cancel"`). Needs the `AgentixComposer` JS hook for Enter-to-send. """ attr(:streaming?, :boolean, default: false) attr(:placeholder, :string, default: "Message the assistant…") def composer(assigns) do ~H"""
Enter to send · Shift+Enter for newline
""" end @doc "The CSS for turn grouping and the reasoning chevron. Inline once." @spec css() :: String.t() def css do """ /* A turn is a run of rows sharing a `data-group` ("user", or "agent" = assistant + its tool calls). A hairline divider is drawn only where the group changes — i.e. between turns — so the whole of one assistant turn (text, tools, more text) reads as a single block with no internal lines. */ .agentix-thread > [data-group="user"] + [data-group="agent"], .agentix-thread > [data-group="agent"] + [data-group="user"] { border-top: 1px solid rgb(229 229 229 / 0.7); } .dark .agentix-thread > [data-group="user"] + [data-group="agent"], .dark .agentix-thread > [data-group="agent"] + [data-group="user"] { border-top-color: rgb(38 38 38 / 0.7); } /* Continuation rows within a turn: pull up to merge with the row above and hide the repeated avatar + header, so a turn shows exactly one header. */ .agentix-thread > [data-group="agent"] + [data-group="agent"], .agentix-thread > [data-group="user"] + [data-group="user"] { padding-top: 0.25rem; margin-top: -0.75rem; } .agentix-thread > [data-group="agent"] + [data-group="agent"] > .agentix-avatar, .agentix-thread > [data-group="user"] + [data-group="user"] > .agentix-avatar { visibility: hidden; } .agentix-thread > [data-group="agent"] + [data-group="agent"] .agentix-role-header, .agentix-thread > [data-group="user"] + [data-group="user"] .agentix-role-header { display: none; } details[open] .agentix-chev { transform: rotate(90deg); } /* Markdown bodies render to HTML inside `.agentix-md`. A CSS reset (e.g. Tailwind's preflight) strips list bullets and heading sizes, so restore readable defaults here, scoped so they never leak into the host's own styles. */ .agentix-md > :first-child { margin-top: 0; } .agentix-md > :last-child { margin-bottom: 0; } .agentix-md p { margin: 0.5em 0; } .agentix-md h1, .agentix-md h2, .agentix-md h3, .agentix-md h4 { font-weight: 600; line-height: 1.3; margin: 1em 0 0.4em; } .agentix-md h1 { font-size: 1.3em; } .agentix-md h2 { font-size: 1.15em; } .agentix-md h3 { font-size: 1.05em; } .agentix-md ul, .agentix-md ol { margin: 0.5em 0; padding-left: 1.5em; } .agentix-md ul { list-style: disc; } .agentix-md ol { list-style: decimal; } .agentix-md li { margin: 0.2em 0; } .agentix-md li > ul, .agentix-md li > ol { margin: 0.2em 0; } .agentix-md pre { margin: 0.6em 0; padding: 0.75em 0.9em; border-radius: 0.5rem; overflow-x: auto; font-size: 0.85em; line-height: 1.45; background: rgb(0 0 0 / 0.05); } .agentix-md :not(pre) > code { font-size: 0.88em; padding: 0.1em 0.35em; border-radius: 0.3rem; background: rgb(0 0 0 / 0.06); } .agentix-md pre code { background: none; padding: 0; font-size: 1em; } .agentix-md blockquote { margin: 0.6em 0; padding-left: 0.9em; border-left: 3px solid rgb(0 0 0 / 0.15); opacity: 0.85; } .agentix-md a { text-decoration: underline; } .agentix-md strong { font-weight: 600; } .agentix-md hr { margin: 1em 0; border: 0; border-top: 1px solid rgb(0 0 0 / 0.1); } .agentix-md table { border-collapse: collapse; margin: 0.6em 0; } .agentix-md th, .agentix-md td { border: 1px solid rgb(0 0 0 / 0.15); padding: 0.3em 0.6em; } .dark .agentix-md pre { background: rgb(255 255 255 / 0.06); } .dark .agentix-md :not(pre) > code { background: rgb(255 255 255 / 0.08); } .dark .agentix-md blockquote { border-left-color: rgb(255 255 255 / 0.2); } .dark .agentix-md hr { border-top-color: rgb(255 255 255 / 0.12); } .dark .agentix-md th, .dark .agentix-md td { border-color: rgb(255 255 255 / 0.15); } """ end ## --- private --- attr(:role, :atom, required: true) defp role_header(assigns) do ~H"""
{role_label(@role)}
""" end attr(:role, :atom, required: true) defp avatar(%{role: :user} = assigns) do ~H"""
<.icon name={:user} class="h-4 w-4" />
""" end defp avatar(assigns) do ~H"""
<.icon name={:star} class="h-4 w-4" />
""" end attr(:status, :atom, required: true) defp tool_icon(%{status: :ok} = assigns) do ~H""" <.icon name={:check} class="h-3.5 w-3.5 text-emerald-600 dark:text-emerald-500" /> """ end defp tool_icon(%{status: :error} = assigns) do ~H""" <.icon name={:x} class="h-3.5 w-3.5 text-red-600 dark:text-red-500" /> """ end defp tool_icon(assigns) do ~H""" <.icon name={:spinner} class="h-3.5 w-3.5 animate-spin text-neutral-400" /> """ end attr(:name, :atom, required: true) attr(:class, :any, default: nil) defp icon(%{name: :star} = assigns) do ~H""" """ end defp icon(%{name: :user} = assigns) do ~H""" """ end defp icon(%{name: :spinner} = assigns) do ~H""" """ end defp icon(%{name: :check} = assigns) do ~H""" """ end defp icon(%{name: :x} = assigns) do ~H""" """ end defp icon(%{name: :warning} = assigns) do ~H""" """ end defp icon(%{name: :send} = assigns) do ~H""" """ end defp tool_border(:error), do: "border-red-300/70 bg-red-50 dark:border-red-500/30 dark:bg-red-500/10" defp tool_border(_status), do: "border-neutral-200 dark:border-neutral-800" defp tool_text(:error), do: "text-red-700 dark:text-red-300" defp tool_text(_status), do: "text-neutral-700 dark:text-neutral-200" defp tool_meta(:error), do: "text-red-600/80 dark:text-red-400/80" defp tool_meta(_status), do: "text-neutral-400" defp tool_label(:running), do: "running" defp tool_label(:ok), do: "done" defp tool_label(:error), do: "error" defp banner_class(:warning), do: "border-amber-300/70 bg-amber-50 dark:border-amber-500/30 dark:bg-amber-500/10" defp banner_class(_variant), do: "border-red-300/70 bg-red-50 dark:border-red-500/30 dark:bg-red-500/10" # One glyph for both variants; the colour (see `banner_icon_color/1`) distinguishes them. defp banner_icon(_variant), do: :warning defp banner_icon_color(:warning), do: "text-amber-600 dark:text-amber-500" defp banner_icon_color(_variant), do: "text-red-600 dark:text-red-500" defp banner_text(:warning), do: "text-amber-900 dark:text-amber-200" defp banner_text(_variant), do: "text-red-800 dark:text-red-200" defp role_label(:user), do: "You" defp role_label(:assistant), do: "Assistant" defp role_label(role), do: role |> to_string() |> String.capitalize() defp message_text(%{content: parts}) when is_list(parts), do: parts |> Enum.map(&Map.get(&1, :text)) |> Enum.reject(&is_nil/1) |> Enum.join("") defp message_text(_message), do: "" # Markdown rendering is on by default; a host opts out with # `config :agentix, render_markdown: false`. The markdown engine is the host's (wired # into the JS hook via `configureMarkdown/1`) — see `agentix_stream_hook.js`. defp markdown?, do: Application.get_env(:agentix, :render_markdown, true) defp markdown_hook(id) when is_binary(id) do if markdown?(), do: "AgentixMarkdown" end defp markdown_hook(_id), do: nil # Turn grouping: user messages are their own group; assistant and tool messages share # the "agent" group so a turn's text + tool rows collapse under one header. defp group(:user), do: "user" defp group(_role), do: "agent" defp tool_name(%{metadata: %{"tool_name" => name}}) when is_binary(name), do: name defp tool_name(_message), do: "tool" # A live tool's status line: a binary `:tool_progress` payload if one has arrived, else # any explicit `:meta`. Non-binary progress is left to a host's own renderer. defp tool_meta_label(%{progress: progress}) when is_binary(progress), do: progress defp tool_meta_label(tool), do: Map.get(tool, :meta) defp tool_status(%{metadata: %{"tool_status" => "error"}}), do: :error defp tool_status(_message), do: :ok defp prompt_label(%{kind: :approval}), do: "Approval required to continue." defp prompt_label(%{kind: :elicitation}), do: "The assistant needs more information." defp prompt_label(%{kind: :client_exec}), do: "A client action is requested." defp prompt_label(_entry), do: "Pending" defp input_name(%{kind: :client_exec}), do: "result" defp input_name(_entry), do: "answer" end