defmodule OpenAgentsWeb.ThreadShowLive do
@moduledoc """
One thread's transcript, read-only, live.
Private by default: an unknown id and another account's `dark` thread both
raise the plain 404 (`OpenAgentsWeb.PublicNotFoundError`), matching how the
API scopes the same read — existence is never confirmed to a reader the
thread's transparency tier does not admit.
A thread opened at a wider tier is readable here by any signed-in account
holding its id, which is the audience this route can actually enforce: it
sits in the `:authenticated` live session, so there is no anonymous reader to
admit and none is invented. What the tier does not disclose is the owner's
money — the budget card is the account's grant, and it renders for the owner
only (THREAD-002).
The snapshot-to-live order follows the projection protocol the issue names:
subscribe to the thread's topic first, then read the snapshot, then let
buffered broadcasts drain through `handle_info/2` with a monotonic
`last_event_id` dedup, so an event is never dropped and never doubled.
The event vocabulary renders typed — `turn.user`, `turn.reasoning`,
`tool.ran`, `turn.assistant` — and every payload field is read defensively:
the payloads are client-written and their shape is not pinned by the server,
so a missing or oddly typed field degrades to the neutral raw row rather
than crashing the view. Unknown event types take the raw row too.
"""
use OpenAgentsWeb, :live_view
alias OpenAgents.Inference.Pricing
alias OpenAgents.Markdown
alias OpenAgents.Threads
# Transcript pages are capped at 50 by the context; forty pages bounds the
# view at 2,000 events, which is past any measured session.
@maximum_pages 40
@bounded_json_characters 2_000
@impl true
def mount(%{"id" => thread_id}, _session, socket) do
user = socket.assigns.current_user
_reaped = Threads.reap_expired(user)
case Threads.fetch_readable(user, thread_id) do
:error ->
raise OpenAgentsWeb.PublicNotFoundError, message: "thread not found"
{:ok, thread, relation} ->
# Attach the live subscriber before reading the snapshot: an append
# that lands between the two arrives as a buffered message and is
# deduped below by id, so the gap cannot lose an event.
if connected?(socket), do: Threads.subscribe(thread)
events = transcript(thread)
owner? = relation == :owner
{:ok,
socket
|> assign(:page_title, "Thread · OpenAgents")
|> assign(:thread, thread)
|> assign(:owner?, owner?)
|> assign(:grant, owner? && Threads.latest_grant(thread))
|> assign(:last_event_id, last_id(events))
|> assign(:events_empty?, events == [])
|> stream(:events, events)}
end
end
@impl true
def handle_info({:thread_event, event}, socket) do
if event.id <= socket.assigns.last_event_id do
{:noreply, socket}
else
{:noreply,
socket
|> assign(:last_event_id, event.id)
|> assign(:events_empty?, false)
|> update(:thread, fn thread -> %{thread | event_count: thread.event_count + 1} end)
|> stream_insert(:events, event)}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
sidebar_sections={assigns[:sidebar_sections]}
current_scope={@current_scope}
>
<main id="thread-show" class="mx-auto w-full max-w-4xl space-y-6 px-4 py-10">
<header class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<.badge variant={status_variant(@thread.status)}>{@thread.status}</.badge>
<span class="font-mono text-xs text-muted-foreground">{@thread.id}</span>
</div>
<h1 class="text-2xl font-semibold tracking-tight">{@thread.objective}</h1>
<dl
id="thread-facts"
class="flex flex-wrap gap-x-6 gap-y-1 text-sm text-muted-foreground"
>
<div class="flex gap-1.5">
<dt>Model</dt>
<%!-- To the catalog, where this lane's rates and the authority
behind them are readable. The cost cell below says what this
session spent; this says what a call on it costs. --%>
<dd class="font-mono text-xs leading-5">
<.link navigate={~p"/models"} id="thread-model-link" class="hover:underline">
{@thread.model}
</.link>
</dd>
</div>
<div class="flex gap-1.5">
<dt>Reasoning</dt>
<dd>{@thread.reasoning_effort}</dd>
</div>
<div class="flex gap-1.5">
<dt>Permissions</dt>
<dd>{@thread.permission_profile}</dd>
</div>
<div class="flex gap-1.5">
<dt>Visibility</dt>
<dd id="thread-visibility">{visibility_label(@thread.visibility)}</dd>
</div>
<div class="flex gap-1.5">
<dt>Events</dt>
<dd id="thread-event-count" class="tabular-nums">{@thread.event_count}</dd>
</div>
<div class="flex gap-1.5">
<dt>Opened</dt>
<dd><.time_ago at={@thread.started_at} /></dd>
</div>
<div :if={@thread.completed_at} class="flex gap-1.5">
<dt>Completed</dt>
<dd><.time_ago at={@thread.completed_at} /></dd>
</div>
</dl>
</header>
<.card :if={@grant} id="thread-budget" class="text-sm">
<div class="flex flex-wrap gap-x-8 gap-y-2">
<div>
<div class="text-xs text-muted-foreground">Grant</div>
<div>{@grant.status}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Calls</div>
<div class="tabular-nums">{@grant.call_count} / {ceiling(@grant.max_calls)}</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Tokens</div>
<div class="tabular-nums">
{grant_spent(@grant, "total_tokens")} / {ceiling(@grant.max_total_tokens)}
</div>
</div>
<div>
<div class="text-xs text-muted-foreground">Cost</div>
<div id="thread-budget-cost" class="tabular-nums" data-basis={cost_basis(@grant)}>
{cost_spent(@grant)} / {dollars(@grant.max_cost_microusd)}
</div>
<div
:if={cost_basis(@grant) != "declared"}
id="thread-budget-cost-note"
class="text-xs text-muted-foreground"
>
{cost_note(cost_basis(@grant))}
</div>
</div>
<div :if={@grant.expires_at}>
<div class="text-xs text-muted-foreground">Expires</div>
<div><.time_ago at={@grant.expires_at} /></div>
</div>
</div>
</.card>
<.card :if={@thread.report} id="thread-report" class="text-sm">
<div class="mb-1 text-xs text-muted-foreground">Report</div>
<div class="whitespace-pre-wrap">{@thread.report}</div>
</.card>
<section aria-label="Transcript" class="space-y-3">
<.empty :if={@events_empty?} id="thread-transcript-empty" title="No events yet">
The transcript fills as the thread works.
</.empty>
<div id="thread-events" phx-update="stream" class="space-y-3">
<div :for={{dom_id, event} <- @streams.events} id={dom_id} data-kind={event.event_type}>
<.event_row event={event} />
</div>
</div>
</section>
</main>
</Layouts.app>
"""
end
# ── event rendering ──────────────────────────────────────────────────────
# One transcript entry, dispatched on its type. Each typed clause reads its
# payload defensively and falls back to the neutral raw row when the field
# it renders around is missing, so a malformed payload degrades instead of
# crashing the transcript.
defp event_row(%{event: %{event_type: "turn.user"} = event} = assigns) do
case text(event.payload) do
nil ->
raw_row(assigns)
text ->
assigns = assign(assigns, text: text, steered: steered?(event.payload))
~H"""
<.card class="text-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">You</span>
<.badge :if={@steered} variant={:warning}>steered</.badge>
<.time_ago at={@event.emitted_at} />
</div>
<div class="whitespace-pre-wrap">{@text}</div>
</.card>
"""
end
end
defp event_row(%{event: %{event_type: "turn.reasoning"} = event} = assigns) do
case text(event.payload) do
nil ->
raw_row(assigns)
text ->
assigns = assign(assigns, text: text)
~H"""
<details class="rounded-md border border-border">
<summary class="cursor-pointer select-none px-3 py-2 text-xs text-muted-foreground">
Reasoning · {String.length(@text)} chars · <.time_ago at={@event.emitted_at} />
</summary>
<div class="whitespace-pre-wrap border-t border-border px-3 py-2 text-sm text-muted-foreground">
{@text}
</div>
</details>
"""
end
end
defp event_row(%{event: %{event_type: "tool.ran"} = event} = assigns) do
case string(event.payload, ["tool", "name"]) do
nil ->
raw_row(assigns)
tool ->
assigns =
assign(assigns,
tool: tool,
tool_status: string(event.payload, ["status"]),
arguments: bounded_json(Map.get(event.payload, "arguments")),
result: bounded_json(Map.get(event.payload, "result"))
)
~H"""
<details class="rounded-md border border-border">
<summary class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs">
<.kbd>{@tool}</.kbd>
<.badge :if={@tool_status} variant={tool_status_variant(@tool_status)}>
{@tool_status}
</.badge>
<span class="text-muted-foreground"><.time_ago at={@event.emitted_at} /></span>
</summary>
<div class="space-y-2 border-t border-border px-3 py-2 text-xs">
<div :if={@arguments}>
<div class="mb-1 text-muted-foreground">Arguments</div>
<pre class="overflow-x-auto rounded-md bg-muted p-2 font-mono"><code phx-no-format>{@arguments}</code></pre>
</div>
<div :if={@result}>
<div class="mb-1 text-muted-foreground">Result</div>
<pre class="overflow-x-auto rounded-md bg-muted p-2 font-mono"><code phx-no-format>{@result}</code></pre>
</div>
</div>
</details>
"""
end
end
defp event_row(%{event: %{event_type: "turn.assistant"} = event} = assigns) do
case text(event.payload) do
nil ->
raw_row(assigns)
text ->
assigns =
assign(assigns,
html: Markdown.to_html(text),
usage: usage_line(event.payload)
)
~H"""
<.card class="text-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">Assistant</span>
<.time_ago at={@event.emitted_at} />
</div>
<div class="markdown space-y-2">{@html}</div>
<div :if={@usage} class="mt-2 text-xs tabular-nums text-muted-foreground">{@usage}</div>
</.card>
"""
end
end
defp event_row(assigns), do: raw_row(assigns)
# The neutral row: whatever the type, whatever the payload, it renders as
# the recorded fact rather than crashing the transcript around it.
defp raw_row(assigns) do
assigns = assign(assigns, json: bounded_json(assigns.event.payload))
~H"""
<details class="rounded-md border border-border">
<summary class="flex cursor-pointer select-none items-center gap-2 px-3 py-2 text-xs">
<code class="font-mono">{@event.event_type}</code>
<span class="text-muted-foreground"><.time_ago at={@event.emitted_at} /></span>
</summary>
<pre
:if={@json}
class="overflow-x-auto border-t border-border px-3 py-2 font-mono text-xs"
><code phx-no-format>{@json}</code></pre>
</details>
"""
end
# ── snapshot ─────────────────────────────────────────────────────────────
defp transcript(thread), do: transcript(thread, nil, @maximum_pages, [])
defp transcript(_thread, _after_id, 0, pages), do: pages |> Enum.reverse() |> List.flatten()
defp transcript(thread, after_id, remaining, pages) do
page = Threads.list_events(thread, after: after_id)
case last_id(page) do
0 -> transcript(thread, after_id, 0, pages)
last -> transcript(thread, last, remaining - 1, [page | pages])
end
end
defp last_id([]), do: 0
defp last_id(events), do: List.last(events).id
# ── payload readers ──────────────────────────────────────────────────────
defp text(payload), do: string(payload, ["text"])
defp string(payload, keys) when is_map(payload) do
Enum.find_value(keys, fn key ->
case Map.get(payload, key) do
value when is_binary(value) and value != "" -> value
_other -> nil
end
end)
end
defp string(_payload, _keys), do: nil
defp steered?(payload) when is_map(payload), do: Map.get(payload, "steered") == true
defp steered?(_payload), do: false
defp bounded_json(nil), do: nil
defp bounded_json(value) do
case Jason.encode(value, pretty: true) do
{:ok, json} when byte_size(json) > @bounded_json_characters ->
String.slice(json, 0, @bounded_json_characters) <> "\n…"
{:ok, json} ->
json
{:error, _unencodable} ->
inspect(value, limit: 50, printable_limit: @bounded_json_characters)
end
end
defp usage_line(payload) when is_map(payload) do
case Map.get(payload, "usage") do
usage when is_map(usage) ->
parts =
[
{"input", integer(usage, "input_tokens") || integer(usage, "prompt_tokens")},
{"output", integer(usage, "output_tokens") || integer(usage, "completion_tokens")},
{"total", integer(usage, "total_tokens")}
]
|> Enum.filter(fn {_label, value} -> value end)
|> Enum.map(fn {label, value} -> "#{value} #{label}" end)
if parts == [], do: nil, else: Enum.join(parts, " · ") <> " tok"
_absent ->
nil
end
end
defp usage_line(_payload), do: nil
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
defp grant_spent(grant, key) do
case grant.usage do
usage when is_map(usage) -> integer(usage, key) || 0
_absent -> 0
end
end
# A null ceiling is unbounded, not zero and not missing. Rendering nil left
# the denominator blank, which read as a rendering fault rather than as the
# thread's actual authority.
defp ceiling(nil), do: "∞"
defp ceiling(value), do: value
defp dollars(nil), do: "∞"
defp dollars(microusd), do: "$#{:erlang.float_to_binary(microusd / 1_000_000, decimals: 2)}"
# What this grant has spent, or the word for not knowing. `$0.00` is what
# this cell used to show for a lane with no declared rates, and it was the
# most confident wrong number on the page: the coder runs on that lane
# (METER-001).
defp cost_spent(grant) do
case Pricing.cost(grant.usage) do
nil -> "Unpriced"
microusd -> dollars(microusd)
end
end
defp cost_basis(grant), do: Pricing.usage_basis(grant.usage)
defp cost_note("unpriced"),
do: "No rates declared for this model, so this session's cost is unknown rather than zero."
defp cost_note(_provisional),
do: "Provisional rates. This is a working figure, not a bill."
# The tier word plus what it means for a reader, because "dark" alone tells
# the owner nothing about who can see the page they are looking at.
defp visibility_label("dark"), do: "dark · only you"
defp visibility_label("ledger"), do: "ledger · anyone signed in with the link"
defp visibility_label(visibility), do: visibility
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
defp tool_status_variant("ok"), do: :success
defp tool_status_variant("success"), do: :success
defp tool_status_variant("error"), do: :danger
defp tool_status_variant("failed"), do: :danger
defp tool_status_variant(_status), do: :default
end