Skip to main content

lib/omni/ui/sessions_component.ex

defmodule Omni.UI.SessionsComponent do
  @moduledoc """
  A LiveComponent that renders the sessions sidebar.

  Permanently mounted as a left sidebar over the main chat interface.
  Lists every persisted session plus any currently-running session that
  hasn't been persisted yet, with running sessions sorted to the top.
  Updates live in response to `Omni.Session.Manager` events.

  ## Assigns from parent

    * `id` — required Phoenix component id
    * `current_id` — the currently active session id (for row highlighting)
    * `manager` — the `Omni.Session.Manager` module to query

  ## Live updates

  Manager events are not delivered to LiveComponents directly — the parent
  LiveView receives `{:manager, _, _, _}` messages and forwards them with
  `send_update/2`, passing the event under a `manager_event:` assign that
  this component pattern-matches:

      def handle_info({:manager, _, _, _} = msg, socket) do
        send_update(Omni.UI.SessionsComponent, id: "sessions", manager_event: msg)
        {:noreply, socket}
      end

  ## Events bubbled to the parent LiveView (not `phx-target`-ed)

    * `open_session` with `session-id` — parent should `push_patch` to the
      session URL
    * `new_session` — parent should `push_patch` to `/`
    * `:active_session_deleted` — sent as a process message when the user
      deletes the currently active session
  """

  use Phoenix.LiveComponent

  import Omni.UI.CoreUI
  import Omni.UI.SessionsUI

  attr :current_id, :string, default: nil
  attr :manager, :atom, required: true

  @impl true
  def render(assigns) do
    ~H"""
    <aside class="omni-ui h-full">
      <.panel body_class="overflow-y-auto">
        <:header>
          <.panel_header title="Sessions" align="left">
            <:right>
              <button
                type="button"
                phx-click="new_session"
                title="New session"
                class="flex items-center justify-center size-8 rounded cursor-pointer text-omni-text-1 hover:text-omni-accent-1 hover:bg-omni-accent-2/10">
                <Lucideicons.plus class="size-4" />
              </button>
            </:right>
          </.panel_header>
        </:header>

        <.session_list
          sessions={@sessions}
          current_id={@current_id}
          target={@myself} />
      </.panel>
    </aside>
    """
  end

  @impl true
  def mount(socket) do
    {:ok, assign(socket, :sessions, nil)}
  end

  @impl true
  def update(%{manager_event: event}, socket) do
    sessions = apply_event(event, socket.assigns.sessions)
    {:ok, assign(socket, :sessions, sessions)}
  end

  def update(assigns, socket) do
    socket = assign(socket, assigns)

    socket =
      if socket.assigns.sessions == nil do
        assign(socket, :sessions, load_sessions(socket.assigns.manager))
      else
        socket
      end

    {:ok, socket}
  end

  @impl true
  def handle_event("rename", %{"session_id" => id, "title" => title}, socket) do
    title = String.trim(title)
    title = if title == "", do: nil, else: title
    socket.assigns.manager.rename(id, title)
    {:noreply, socket}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    :ok = socket.assigns.manager.delete(id)

    if id == socket.assigns.current_id do
      send(self(), :active_session_deleted)
    end

    sessions = Enum.reject(socket.assigns.sessions, &(&1.id == id))
    {:noreply, assign(socket, :sessions, sessions)}
  end

  # ── Helpers ────────────────────────────────────────────────────────

  defp load_sessions(manager) do
    {:ok, persisted} = manager.list()
    open = manager.list_open()
    merge(persisted, open) |> sort()
  end

  defp merge(persisted, open) do
    persisted_by_id = Map.new(persisted, &{&1.id, &1})
    open_by_id = Map.new(open, &{&1.id, &1})

    ids = MapSet.union(MapSet.new(Map.keys(persisted_by_id)), MapSet.new(Map.keys(open_by_id)))
    now = DateTime.utc_now()

    Enum.map(ids, fn id ->
      build_entry(id, Map.get(persisted_by_id, id), Map.get(open_by_id, id), now)
    end)
  end

  defp build_entry(id, persisted, open, now) do
    %{
      id: id,
      title: (open && open.title) || (persisted && persisted.title),
      status: open && open.status,
      pid: open && open.pid,
      updated_at: (persisted && persisted.updated_at) || now,
      persisted?: not is_nil(persisted)
    }
  end

  defp sort(sessions) do
    Enum.sort(sessions, fn a, b ->
      DateTime.compare(a.updated_at, b.updated_at) == :gt
    end)
  end

  defp apply_event({:manager, _module, :opened, entry}, sessions) do
    upsert(sessions, entry.id, fn existing ->
      %{
        id: entry.id,
        title: entry.title,
        status: entry.status,
        pid: entry.pid,
        updated_at: (existing && existing.updated_at) || DateTime.utc_now(),
        persisted?: (existing && existing.persisted?) || false
      }
    end)
    |> sort()
  end

  defp apply_event({:manager, _module, :status, %{id: id, status: status}}, sessions) do
    update_in_list(sessions, id, fn s -> %{s | status: status} end)
    |> sort()
  end

  defp apply_event({:manager, _module, :title, %{id: id, title: title}}, sessions) do
    update_in_list(sessions, id, fn s -> %{s | title: title} end)
  end

  defp apply_event({:manager, _module, :closed, %{id: id}}, sessions) do
    Enum.flat_map(sessions, fn
      %{id: ^id, persisted?: true} = s -> [%{s | status: nil, pid: nil}]
      %{id: ^id} -> []
      s -> [s]
    end)
    |> sort()
  end

  defp apply_event(_other, sessions), do: sessions

  defp upsert(sessions, id, fun) do
    case Enum.find_index(sessions, &(&1.id == id)) do
      nil -> [fun.(nil) | sessions]
      idx -> List.update_at(sessions, idx, fn s -> fun.(s) end)
    end
  end

  defp update_in_list(sessions, id, fun) do
    Enum.map(sessions, fn
      %{id: ^id} = s -> fun.(s)
      s -> s
    end)
  end
end