Conversation UI Guide

Copy Markdown View Source

This guide covers building a Phoenix LiveView chat interface for interactive actors. It assumes you've already set up the actor, LLM client, tools, and dispatch from the Interactive Actors guide.

Overview

The conversation UI consists of:

ComponentPurpose
Cyclium.Conversations.LiveHelpersLibrary-level helpers for bus handling, message loading, and dispatch
ConversationComponentsShared function components (header, message list, message bubble, input bar)
ConversationLive.ShowChat page — creates/loads conversation, sends messages, displays responses
ConversationLive.IndexList page — shows all conversations for the actor
JS HooksScrollToBottom (auto-scroll on new messages), FocusInput (refocus after send)
CSSChat container layout, message bubbles, input bar
Routes/conversations, /conversations/new, /conversations/:id

1. Library Helpers

Cyclium.Conversations.LiveHelpers provides reusable functions that eliminate most of the boilerplate in conversation LiveViews. Use it with the use macro:

use Cyclium.Conversations.LiveHelpers, actor_id: "support_actor"

This sets two module attributes and imports all helper functions:

Available functions

FunctionReturnsPurpose
load_or_create_conversation/4{conversation, messages}Mount helper — loads existing or creates new
load_messages_from_episodes/1[%{role, content, timestamp}]Reconstructs chat history from episodes
on_episode_completed/4{:ok, assigns_map} or :ignoreHandles episode.completed bus event (verifies conversation + actor)
on_episode_failed/4{:ok, assigns_map} or :ignoreHandles episode.failed bus event (verifies conversation + actor)
on_conversation_status_change/2{:ok, conversation} or :ignoreHandles conversation lifecycle events
dispatch_message/5{:ok, messages} or {:error, messages, reason}Sends a message and returns updated message list

The helpers return plain data — they don't call assign or put_flash directly. This keeps the library free of Phoenix compile-time dependencies and gives you full control over socket updates.

2. Shared Components

Create a function component module for reusable chat UI elements:

defmodule MyAppWeb.ConversationComponents do
  use Phoenix.Component

  attr :conversation, :map, required: true

  def conversation_header(assigns) do
    status_class =
      case assigns.conversation.status do
        "open" -> "badge badge-green"
        "resolved" -> "badge"
        "abandoned" -> "badge badge-red"
        "timed_out" -> "badge badge-yellow"
        _ -> "badge"
      end

    assigns = assign(assigns, :status_class, status_class)

    ~H"""
    <div class="chat-header">
      <div style="display: flex; align-items: center; gap: 0.75rem;">
        <h2 style="margin: 0; font-size: 1rem; font-weight: 600;">{@conversation.name}</h2>
        <span class={@status_class}>{@conversation.status}</span>
      </div>
      <div class="text-muted text-sm">
        Turns: {@conversation.turns_used || 0}
      </div>
    </div>
    """
  end

  attr :messages, :list, required: true
  attr :sending, :boolean, default: false

  def message_list(assigns) do
    ~H"""
    <div id="message-list" class="chat-messages" phx-hook="ScrollToBottom">
      <div :if={@messages == []} class="empty-state" style="padding: 3rem;">
        <p class="text-muted">Start a conversation by typing a message below.</p>
      </div>
      <.message_bubble :for={msg <- @messages} message={msg} />
      <div :if={@sending} class="message-bubble message-assistant">
        <div class="message-content">
          <span class="badge badge-evaluating">thinking...</span>
        </div>
      </div>
    </div>
    """
  end

  attr :message, :map, required: true

  def message_bubble(assigns) do
    bubble_class =
      case assigns.message.role do
        :user -> "message-bubble message-user"
        :assistant -> "message-bubble message-assistant"
        _ -> "message-bubble message-assistant"
      end

    assigns = assign(assigns, :bubble_class, bubble_class)

    ~H"""
    <div class={@bubble_class}>
      <div class="message-role">{role_label(@message.role)}</div>
      <div class="message-content">{@message.content}</div>
      <div :if={@message[:timestamp]} class="message-time">
        {format_time(@message.timestamp)}
      </div>
    </div>
    """
  end

  attr :sending, :boolean, default: false
  attr :conversation_open, :boolean, default: true

  def input_bar(assigns) do
    ~H"""
    <div class="chat-input-bar">
      <form phx-submit="send_message" style="display: flex; gap: 0.5rem; width: 100%;">
        <input
          type="text"
          name="message"
          placeholder={
            if @conversation_open, do: "Type a message...", else: "Conversation ended"
          }
          autocomplete="off"
          disabled={@sending or not @conversation_open}
          class="chat-input"
          id="chat-input"
          phx-hook="FocusInput"
        />
        <button
          type="submit"
          class="btn btn-primary"
          disabled={@sending or not @conversation_open}
        >
          {if @sending, do: "Sending...", else: "Send"}
        </button>
      </form>
    </div>
    """
  end

  defp role_label(:user), do: "You"
  defp role_label(:assistant), do: "Assistant"
  defp role_label(_), do: "System"

  defp format_time(%DateTime{} = dt), do: Calendar.strftime(dt, "%H:%M:%S")
  defp format_time(_), do: ""
end

Key design decisions:

  • message_list uses phx-hook="ScrollToBottom" to auto-scroll when new messages arrive
  • The "thinking..." indicator shows when @sending is true (between message dispatch and episode completion)
  • input_bar disables when sending or when the conversation is no longer open (resolved/abandoned/timed_out)
  • FocusInput hook refocuses the text input after each form submit

3. Chat Page (ConversationLive.Show)

The chat page uses LiveHelpers to handle the heavy lifting. Compare with the "without helpers" version in the collapsed section below — the helpers cut the LiveView roughly in half.

defmodule MyAppWeb.ConversationLive.Show do
  use MyAppWeb, :live_view
  use Cyclium.Conversations.LiveHelpers, actor_id: "support_actor"

  import MyAppWeb.ConversationComponents

  @principal %{"type" => "user", "id" => "demo_user", "label" => "Demo User"}

  @impl true
  def mount(params, _session, socket) do
    if connected?(socket), do: Cyclium.Bus.subscribe()

    {conversation, messages} =
      load_or_create_conversation(params, socket.assigns.live_action, @__actor_id, @principal)

    {:ok,
     socket
     |> assign(:conversation, conversation)
     |> assign(:messages, messages)
     |> assign(:sending, false)
     |> assign(:page_title, "Chat: #{conversation.name}")}
  end

  @impl true
  def handle_params(_params, _url, socket), do: {:noreply, socket}

  # --- Events ---

  @impl true
  def handle_event("send_message", %{"message" => message}, socket) do
    message = String.trim(message)

    if message == "" do
      {:noreply, socket}
    else
      case dispatch_message(
             socket.assigns.conversation.id,
             message,
             socket.assigns.messages,
             @__dispatch,
             principal: @principal
           ) do
        {:ok, messages} ->
          {:noreply, assign(socket, messages: messages, sending: true)}

        {:error, messages, _reason} ->
          {:noreply,
           socket
           |> assign(:messages, messages)
           |> put_flash(:error, "Failed to dispatch episode")}
      end
    end
  end

  def handle_event("new_conversation", _params, socket) do
    {:noreply, push_navigate(socket, to: ~p"/conversations/new")}
  end

  # --- Bus events ---

  @impl true
  def handle_info({:bus, "episode.completed", %{episode_id: eid, actor_id: @__actor_id}}, socket) do
    case on_episode_completed(eid, socket.assigns.conversation.id, @__actor_id, socket.assigns.messages) do
      {:ok, new_assigns} -> {:noreply, assign(socket, new_assigns)}
      :ignore -> {:noreply, socket}
    end
  end

  def handle_info({:bus, "episode.failed", %{episode_id: eid, actor_id: @__actor_id}}, socket) do
    case on_episode_failed(eid, socket.assigns.conversation.id, @__actor_id, socket.assigns.messages) do
      {:ok, new_assigns} -> {:noreply, assign(socket, new_assigns)}
      :ignore -> {:noreply, socket}
    end
  end

  def handle_info({:bus, event, %{conversation_id: conv_id}}, socket)
      when event in [
             "conversation.resolved",
             "conversation.timed_out",
             "conversation.abandoned"
           ] do
    case on_conversation_status_change(conv_id, socket.assigns.conversation.id) do
      {:ok, conversation} -> {:noreply, assign(socket, :conversation, conversation)}
      :ignore -> {:noreply, socket}
    end
  end

  def handle_info({:bus, _event, _payload}, socket), do: {:noreply, socket}

  # --- Render ---

  @impl true
  def render(assigns) do
    conversation_open = assigns.conversation.status == "open"
    assigns = assign(assigns, :conversation_open, conversation_open)

    ~H"""
    <div class="chat-container">
      <div class="chat-top-bar">
        <div style="display: flex; align-items: center; gap: 0.75rem;">
          <.link navigate={~p"/conversations"} class="text-muted">&larr; Back</.link>
          <.conversation_header conversation={@conversation} />
        </div>
        <div style="display: flex; gap: 0.5rem;">
          <button phx-click="new_conversation" class="btn btn-secondary btn-sm">
            New Chat
          </button>
        </div>
      </div>

      <.message_list messages={@messages} sending={@sending} />
      <.input_bar sending={@sending} conversation_open={@conversation_open} />
    </div>
    """
  end
end

Key patterns

Bus event handling — the double filter:

  1. actor_id: @__actor_id in the pattern match — an early filter that ignores events from workflow step episodes
  2. on_episode_completed/4 re-checks both conversation_id and actor_id against the loaded episode — ignores events from other conversations, and enforces the actor match even if step 1's pattern is later loosened

Passing @__actor_id to the helper is what bakes the actor check in. Without actor_id filtering, tools that trigger workflows (like initiate_health_check) would cause the chat to show spurious error/completion messages from the workflow's internal episodes.

Data-returning helpers: The on_* helpers return {:ok, assigns_map} or :ignore — you apply them to the socket with assign/2. This keeps Phoenix-specific code in the LiveView where it belongs.

4. Conversation List Page (ConversationLive.Index)

A simple list of all conversations for the actor, with real-time updates.

defmodule MyAppWeb.ConversationLive.Index do
  use MyAppWeb, :live_view

  alias Cyclium.Conversations

  @actor_id "support_actor"

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket), do: Cyclium.Bus.subscribe()

    conversations = Conversations.list_for_actor(@actor_id)

    {:ok,
     socket
     |> assign(:page_title, "Conversations")
     |> assign(:conversations, conversations)}
  end

  @impl true
  def handle_info({:bus, event, _payload}, socket)
      when event in [
             "conversation.resolved",
             "conversation.abandoned",
             "conversation.timed_out",
             "episode.completed",
             "episode.failed"
           ] do
    conversations = Conversations.list_for_actor(@actor_id)
    {:noreply, assign(socket, :conversations, conversations)}
  end

  def handle_info({:bus, _event, _payload}, socket), do: {:noreply, socket}

  @impl true
  def render(assigns) do
    ~H"""
    <div class="page-header">
      <h1>Conversations</h1>
      <.link navigate={~p"/conversations/new"} class="btn btn-primary">New Conversation</.link>
    </div>

    <table :if={@conversations != []}>
      <thead>
        <tr>
          <th>Name</th>
          <th>Status</th>
          <th>Turns</th>
          <th>Last Updated</th>
          <th></th>
        </tr>
      </thead>
      <tbody>
        <tr :for={conv <- @conversations}>
          <td>
            <.link navigate={~p"/conversations/#{conv.id}"}>{conv.name}</.link>
          </td>
          <td>
            <span class={status_badge(conv.status)}>{conv.status}</span>
          </td>
          <td>{conv.turns_used || 0}</td>
          <td class="text-muted text-sm">
            {Calendar.strftime(conv.updated_at, "%Y-%m-%d %H:%M:%S")}
          </td>
          <td>
            <.link navigate={~p"/conversations/#{conv.id}"} class="btn btn-secondary btn-sm">
              Open
            </.link>
          </td>
        </tr>
      </tbody>
    </table>

    <div :if={@conversations == []} class="empty-state">
      <p class="text-muted">No conversations yet.</p>
    </div>
    """
  end

  defp status_badge("open"), do: "badge badge-green"
  defp status_badge("resolved"), do: "badge"
  defp status_badge("abandoned"), do: "badge badge-red"
  defp status_badge("timed_out"), do: "badge badge-yellow"
  defp status_badge(_), do: "badge"
end

Notes:

  • Conversations.list_for_actor/2 is a library function — queries by actor_id, ordered by updated_at DESC
  • The list refreshes on any relevant bus event (episode completed/failed, conversation status changes)
  • Each row links to the chat page at /conversations/:id

5. Routes

Add inside your existing scope "/" block:

# lib/my_app_web/router.ex
scope "/", MyAppWeb do
  pipe_through :browser

  # ... existing routes ...

  live "/conversations", ConversationLive.Index, :index
  live "/conversations/new", ConversationLive.Show, :new
  live "/conversations/:id", ConversationLive.Show, :show
end

The :new and :show actions are handled by the same LiveView (ConversationLive.Show) via live_action in load_or_create_conversation/4.

6. JS Hooks

Add these hooks to your LiveSocket initialization (typically in root.html.heex or app.js):

let Hooks = {};

// Auto-scroll message list to bottom when new messages arrive
Hooks.ScrollToBottom = {
  mounted() { this.el.scrollTop = this.el.scrollHeight; },
  updated() { this.el.scrollTop = this.el.scrollHeight; }
};

// Refocus the input field after form submit (LiveView clears focus)
Hooks.FocusInput = {
  mounted() {
    this.el.focus();
    this.el.form && this.el.form.addEventListener("submit", () => {
      setTimeout(() => { this.el.value = ""; this.el.focus(); }, 50);
    });
  },
  updated() { this.el.focus(); }
};

let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks
});

Why these hooks matter:

  • Without ScrollToBottom, the user has to manually scroll down after each new message
  • Without FocusInput, the user has to click back into the text field after every message send
  • The setTimeout in FocusInput clears the input value and refocuses after LiveView processes the form submission

7. CSS

Add these styles to your layout or stylesheet. The chat UI uses a flex column layout that fills the available viewport height.

/* Chat container — fills viewport below nav */
.chat-container {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 80px); /* adjust 80px to match your nav height */
  max-width: 800px;
  margin: 0 auto;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}

/* Top bar with conversation header and actions */
.chat-top-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--border-color, #333);
  flex-shrink: 0;
}

/* Scrollable message area */
.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem 0;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

/* Individual message bubble */
.message-bubble {
  max-width: 80%;
  padding: 0.75rem 1rem;
  border-radius: 12px;
  line-height: 1.5;
}

/* User messages — right-aligned, accent color */
.message-user {
  align-self: flex-end;
  background: var(--accent-bg, #1a3a5c);
  border-bottom-right-radius: 4px;
}

/* Assistant messages — left-aligned, surface color */
.message-assistant {
  align-self: flex-start;
  background: var(--surface-bg, #1e1e1e);
  border: 1px solid var(--border-color, #333);
  border-bottom-left-radius: 4px;
}

/* Role label above message content */
.message-role {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  opacity: 0.6;
  margin-bottom: 0.25rem;
}

/* Message content text */
.message-content {
  white-space: pre-wrap;
  word-break: break-word;
}

/* Timestamp below message */
.message-time {
  font-size: 0.7rem;
  opacity: 0.4;
  margin-top: 0.25rem;
  text-align: right;
}

/* Fixed input bar at bottom — extra padding for breathing room */
.chat-input-bar {
  padding: 1rem 0 1.5rem;
  border-top: 1px solid var(--border-color, #333);
  flex-shrink: 0;
  margin-bottom: 0.5rem;
}

/* Text input field */
.chat-input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 1px solid var(--border-color, #333);
  border-radius: 8px;
  background: var(--input-bg, #1a1a1a);
  color: inherit;
  font-size: 0.9rem;
  min-height: 2.75rem;
}

.chat-input:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Thinking indicator */
.badge-evaluating {
  animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
  0%, 100% { opacity: 0.4; }
  50% { opacity: 1; }
}

Layout notes:

  • height: calc(100vh - 80px) — adjust 80px to your actual nav bar height
  • The flex column layout ensures the message area grows to fill space while the input bar stays at the bottom
  • padding-bottom: env(safe-area-inset-bottom) handles mobile safe areas
  • Input bar has extra bottom padding and margin for breathing room
  • CSS variables (--border-color, --surface-bg, etc.) allow easy theming

Add a link to your nav bar:

<a href="/conversations" class="nav-link">Chat</a>

Complete File List

FileWhat it does
lib/my_app_web/components/conversation_components.exShared function components
lib/my_app_web/live/conversation_live/show.exChat page LiveView
lib/my_app_web/live/conversation_live/index.exList page LiveView
lib/my_app_web/router.exRoutes (3 new lines)
lib/my_app_web/components/layouts/root.html.heexNav link + JS hooks + CSS

Common Issues

"Sorry, something went wrong" from unrelated actors

If your interactive actor's tools trigger workflows (e.g., initiate_health_checkClientHealthWorkflow), the workflow's internal episodes broadcast episode.completed/episode.failed on the Bus. The chat LiveView receives these too.

Fix: Pattern match on actor_id: @__actor_id in both episode.completed and episode.failed handlers. See section 3 above.

Messages don't auto-scroll

Ensure the ScrollToBottom hook is registered in your LiveSocket and the message list div has phx-hook="ScrollToBottom". The hook fires on both mounted() and updated().

Input loses focus after sending

The FocusInput hook handles this. It clears the input value and refocuses after form submission. Without the setTimeout, the focus happens before LiveView processes the event and gets cleared.

"thinking..." indicator stays forever

This means the episode never completed or failed, or the bus event wasn't received. Check:

  1. Is Cyclium.Bus.subscribe() called in mount (inside connected? guard)?
  2. Is the actor_id filter correct in the handle_info pattern?
  3. Did the episode actually complete? Check /episodes page or DB.

Message history lost on page reload

Messages are reconstructed from episodes on mount via load_messages_from_episodes/1 (from LiveHelpers). This queries Episodes.list_for_conversation/2 and extracts user messages from trigger_ref["message"] and assistant responses from episode.summary. If episodes exist but messages don't show, check that trigger_ref is being stored correctly by Cyclium.Conversations.Dispatch.

Conversation shows as "open" after user says goodbye

The LLM can signal conversation resolution by including "meta": {"resolve_conversation": true} in its ActionPlan response. The ConversationHook (called post-converge) checks for this and calls Conversations.resolve/2. The LiveView handles the "conversation.resolved" bus event to update the UI status. If it's not working, check that the system prompt tells the LLM about the meta.resolve_conversation field.