# Rendering
## The tension, and how to resolve it
"100% customizable" and "batteries included" pull in opposite directions. The trap
is resolving that with one big configurable component that takes fifty options — it
always fails to be either. Resolve it with **layers**, borrowing the headless-UI
philosophy.
## Layer 1 — headless (build this first)
A LiveView integration (a `use Agentix.Chat` macro plus an `on_mount` hook) that
owns the conversation assigns and renders *nothing*. It exposes the state and the
verbs; you write your own HEEx.
The mount pattern is **snapshot + live tail** (see `01`), in this order: subscribe
to the live topic first, then fetch the snapshot, then apply. The snapshot is the
canonical log **plus** the agent's in-progress turn state: the agent already
accumulates the streaming text in order to finalize the message, so a mid-stream
mount (second tab, reconnect) fetches the partial text and seeds the JS hook with
it — otherwise the visible message starts mid-sentence. The subscribe-then-fetch
race is benign because the projection is keyed throughout (stream dom-ids,
`tool_call_id`s, last-write-wins `state`): replaying an event already reflected in
the snapshot is a no-op. That idempotence is a property to protect, not an
accident.
### Assigns projection (resolved)
```
%{
messages: <LiveView stream of finalized %ReqLLM.Message{}>,
streaming_message: %{id, thinking} | nil, # text lives in the JS hook mid-stream
state: :idle | :preparing | :streaming | :executing_tools
| :awaiting_input,
streaming?: boolean, # derived from state
in_flight_tools: %{tool_call_id => %{name, executor, progress}},
pending: %{tool_call_id => %{executor, kind, prompt}}
# kind: :approval | :elicitation | :client_exec
}
```
Note what is *not* an assign mid-stream: streaming text. It lives in the JS hook
(see the streaming path below) and only lands in `messages` via `stream_insert` on
finalization. `streaming_message` carries the in-progress id and any thinking
deltas; `streaming?` is derived from `state` and drives both the streaming
indicator and composer-disable.
Helpers it exposes:
- send a user message,
- resolve a pending call (approve/deny, submit an answer),
- cancel the current turn.
This layer is what actually delivers "100% customizable," and it is the one to nail
first, because everything else is optional sugar on top of it.
## Layer 2 — default function components (ownable)
A set of plain function components rendering a clean default chat against that
state: `<.message_list>`, `<.message>`, `<.tool_call>`, `<.composer>`,
`<.thinking>`, plus `<.approval>` and `<.elicitation>` for HITL.
Ship them as **ownable** components — `mix agentix.gen.components` copies the source
into the user's app (the shadcn / Phoenix `core_components.ex` model) so people edit
source rather than fighting config. Function components compose and override far
more gracefully than a monolith.
## Layer 3 — slots
The default components take slots so someone can override just the message bubble,
or just the tool-call card, without rewriting the whole list — e.g.
`<.message_list>` with a `:message` slot that yields the message struct.
## The event contract is the real interface
The renderer is a **projection of the agent's canonical event stream into assigns**.
If the agent publishes a clean, canonical event stream (it does, built on ReqLLM's
`Message` / `ContentPart` / `StreamChunk` types), the renderer is downstream and
swappable. Get the contract right and the UI is trivial to replace; get it wrong and
every renderer leaks provider quirks.
These are the **live events** (the PubSub plane from `01` — ephemeral, lossy-safe,
never the source of truth). Keep the union closed and small: Elixir 1.20 offers
only inference-based redundant-clause warnings (no user-declared sum types, no
exhaustiveness checking — that's milestone 3, ~1.22), so a checkable declared
union is a future upgrade, not a v0 tool:
```
{:state_changed, state}
{:turn_started, turn_ref}
{:text_delta, turn_ref, msg_id, chunk} # → JS hook, not assigns
{:thinking_delta, turn_ref, msg_id, chunk}
{:message_completed, turn_ref, %ReqLLM.Message{}} # → stream_insert
{:tool_call_started, tool_call_id, name, executor, args}
{:tool_progress, tool_call_id, payload} # progressive tools
{:tool_call_resolved, tool_call_id, result}
{:tool_call_errored, tool_call_id, reason}
{:suspended, tool_call_id, executor, prompt} # awaiting human/client
{:turn_completed, turn_ref} | {:cancelled, turn_ref}
```
Each event maps cleanly to an assign mutation: `:state_changed` sets `state`
(and derives `streaming?`); `:text_delta`/`:thinking_delta` push to the JS hook;
`:message_completed` does `stream_insert`; the `:tool_call_*` events maintain
`in_flight_tools`; `:suspended` adds to `pending` and its resolution clears it.
## Executor-aware rendering
The tool `executor` (see `03`) drives what the UI shows for a tool call:
- `:server` — a tool-call card with result, and progress if the tool is progressive.
- `:human` — an elicitation form/prompt; the submitted value resolves the call.
- gated (`:requires_approval`) — a confirm card; approve/deny resolves the gate.
- `:provider` — a "searched the web" style affordance; the result arrives in-stream.
- `:client` — usually executes invisibly; the result returns over the socket.
The discriminator the components actually switch on is `pending[id].kind`
(`:approval` / `:elicitation` / `:client_exec`), not the executor — executor alone
can't distinguish a gated `:server` call from an elicitation, and a gated
`:client` call suspends twice (approval, then client execution — see `03`).
## The streaming token path (the one real perf concern)
Appending tokens by re-rendering the streaming message server-side makes LiveView
re-diff a growing string every chunk. The fix: push token deltas to a small JS hook
via `push_event` and let the client append to the DOM, updating server assigns only
on finalization. Markdown is rendered live, client-side (a settled non-issue), so
the hook owns both append and render during streaming; the server holds the
finalized message.
The message *list* uses `Phoenix.LiveView.stream/3` so the list itself isn't
re-diffed; the single in-progress message is the only thing the JS hook manages
incrementally.
## Resolved decisions
- **Event union + assigns shape** — pinned above. The contract is the live event
union (this doc) plus the canonical log for snapshot/scrollback (`01`, `04`).
- **Client-tool dispatch** (see `03`) — `:client` is `:human` with JS as the "user."
The agent emits `{:suspended, id, :client, args}`; a registered JS hook maps tool
name → client function, executes it, and `pushEvent`s the result back, which calls
the same `resolve` as any other pending call. Often invisible (no prompt rendered).
Security: the server validates `:client` results and never trusts one for a
privileged decision.
- **Approval vs elicitation** — **two components, one mechanism.** The resolution
path is identical, so the headless layer has one `pending` concept and one
resolver, but ship both `<.approval>` (boolean gate) and `<.elicitation>`
(arbitrary form) — don't force a form abstraction over a yes/no.