lib/openagents_web/live/thread_index_live.ex

436d37facc96 · 4 KB

defmodule OpenAgentsWeb.ThreadIndexLive do
  @moduledoc """
  The account's threads as a shell projection.

  Each row reads only the denormalized thread record — status, objective,
  counts, timestamps, terminal usage — never the transcript (issue #201's
  shell/detail split). The row title is the thread's objective: it is what the
  reader asked for, it lives on the shell row, and deriving the first
  `turn.user` event would mean reading transcripts for a listing. The listing
  subscribes to the user's thread topic so newly opened and updated threads
  render live without a page refresh.
  """

  use OpenAgentsWeb, :live_view

  alias OpenAgents.Threads

  @title_characters 100

  @impl true
  def mount(_params, _session, socket) do
    user = socket.assigns.current_user
    _reaped = Threads.reap_expired(user)

    if connected?(socket), do: Threads.subscribe_user(user)

    threads = Threads.list_for_user(user)

    {:ok,
     socket
     |> assign(:page_title, "Threads · OpenAgents")
     |> assign(:threads_empty?, threads == [])
     |> stream(:threads, threads)}
  end

  @impl true
  def handle_info({:thread_created, thread}, socket) do
    {:noreply,
     socket
     |> assign(:threads_empty?, false)
     |> stream_insert(:threads, thread, at: 0)}
  end

  @impl true
  def handle_info({:thread_updated, thread}, socket) do
    {:noreply, stream_insert(socket, :threads, thread)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
    >
      <main id="thread-index" class="mx-auto w-full max-w-5xl space-y-6 px-4 py-10">
        <.header>
          Threads
          <:subtitle>Every thread this account has opened, newest first.</:subtitle>
        </.header>

        <.empty :if={@threads_empty?} id="threads-empty" title="No threads yet">
          Open one with
          <.kbd>openagents coder</.kbd>
          or <.kbd>POST /api/v1/threads</.kbd>.
        </.empty>

        <.table :if={!@threads_empty?} id="threads-table" rows={@streams.threads}>
          <:col :let={{_id, thread}} label="Objective">
            <.link
              navigate={~p"/threads/#{thread.id}"}
              id={"thread-link-#{thread.id}"}
              class="font-medium hover:underline"
            >
              {title(thread.objective)}
            </.link>
            <p
              :if={thread.repository}
              id={"thread-repository-#{thread.id}"}
              class="mt-0.5 font-mono text-xs text-muted-foreground"
            >
              {thread.repository}
            </p>
          </:col>
          <:col :let={{_id, thread}} label="Status">
            <.badge variant={status_variant(thread.status)}>{thread.status}</.badge>
          </:col>
          <:col :let={{_id, thread}} label="Events">
            <span class="tabular-nums">{thread.event_count}</span>
          </:col>
          <:col :let={{_id, thread}} label="Model">
            <span class="font-mono text-xs text-muted-foreground">{thread.model}</span>
          </:col>
          <:col :let={{_id, thread}} label="Opened">
            <.time_ago at={thread.started_at} class="text-muted-foreground" />
          </:col>
          <:col :let={{_id, thread}} label="Last event">
            <.time_ago at={thread.updated_at} class="text-muted-foreground" />
          </:col>
          <:col :let={{_id, thread}} label="Spent">
            <span class="tabular-nums text-muted-foreground">{spent(thread.usage)}</span>
          </:col>
        </.table>
      </main>
    </Layouts.app>
    """
  end

  defp title(objective) do
    if String.length(objective) > @title_characters do
      String.slice(objective, 0, @title_characters) <> "…"
    else
      objective
    end
  end

  defp status_variant("open"), do: :info
  defp status_variant("succeeded"), do: :success
  defp status_variant("failed"), do: :danger
  defp status_variant("cancelled"), do: :dim
  defp status_variant(_status), do: :default

  # The terminal usage map is client-reported and its shape is not pinned, so
  # this reads the keys the grant meter uses and shows nothing otherwise.
  defp spent(usage) when is_map(usage) do
    case integer(usage, "estimated_cost_microusd") do
      nil ->
        case integer(usage, "total_tokens") do
          nil -> "—"
          tokens -> "#{tokens} tok"
        end

      microusd ->
        "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
    end
  end

  defp spent(_usage), do: "—"

  defp integer(map, key) do
    case Map.get(map, key) do
      value when is_integer(value) -> value
      value when is_float(value) -> trunc(value)
      _absent -> nil
    end
  end
end