Show one timeline across voice, coder, and chat

33ca3212429d · AtlantisPleb · · parent c23e2eb7f970

Show one timeline across voice, coder, and chat

The merge shipped and nothing rendered it, so someone who spoke, then
coded, then opened the web still saw three disconnected surfaces.
`/timeline` renders the account's own unified sequence: each entry
named by the modality it came from, when it happened, its kind, and
its summary, in the order the merge decided.

It is a read and is classified as one. The route is authenticated
browser under `timeline:self`, it holds no operator authority, and the
LiveView has no event handlers at all — so it is not in the set of GET
paths the classifier treats as mutations, which the first pass had put
it in.

Scope is the account's own records throughout, which is what keeps
THREAD-002 honest here: the context function already refuses anything
else and this surface does not widen it.

Built by a Devin child through the openagents coder's delegate tool;
the mutation misclassification was corrected in review, and 557
LiveView, route-authority, operator-surface, and timeline tests re-run
before landing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 358 · 2026-08-25T10:32:13.043820Z

Changed files

  • added lib/openagents_web/live/timeline_live.ex
  • modified lib/openagents_web/route_authority.ex
  • modified lib/openagents_web/router.ex
  • added test/openagents_web/live/timeline_live_test.exs

Diff

4 files changed, +147 -0

lib/openagents_web/live/timeline_live.ex added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgentsWeb.TimelineLive do
2
  @moduledoc """
3
  The account's unified timeline as a page.
4
5
  Every entry is rooted in the account's visitor. Only the current account's
6
  own coder, voice, and chat activity is shown, newest first.
7
  """
8
9
  use OpenAgentsWeb, :live_view
10
11
  alias OpenAgents.Timeline
12
13
  @impl true
14
  def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
15
    entries =
16
      Timeline.for_user(current_user)
17
      |> Enum.reverse()
18
19
    {:ok,
20
     socket
21
     |> assign(:page_title, "Timeline")
22
     |> assign(:entries, entries)}
23
  end
24
25
  @impl true
26
  def render(assigns) do
27
    ~H"""
28
    <Layouts.app
29
      flash={@flash}
30
      sidebar_sections={assigns[:sidebar_sections]}
31
      current_scope={@current_scope}
32
    >
33
      <main id="timeline" class="mx-auto w-full max-w-5xl space-y-6 px-4 py-10">
34
        <.header>
35
          Timeline
36
          <:subtitle>Your account's activity, newest first.</:subtitle>
37
        </.header>
38
39
        <.empty :if={@entries == []} id="timeline-empty" title="No activity yet">
40
          Your chat, voice, and coder sessions appear here when they start.
41
        </.empty>
42
43
        <.table
44
          :if={@entries != []}
45
          id="timeline-table"
46
          rows={@entries}
47
          row_id={&"timeline-entry-#{&1.record_id}"}
48
        >
49
          <:col :let={entry} label="When">
50
            <span :if={is_nil(entry.timestamp)} class="text-muted-foreground">—</span>
51
            <.time_ago :if={entry.timestamp} at={entry.timestamp} />
52
          </:col>
53
          <:col :let={entry} label="Modality">
54
            {modality_label(entry.modality)}
55
          </:col>
56
          <:col :let={entry} label="Kind">
57
            <.badge variant={kind_variant(entry.kind)}>{kind_label(entry.kind)}</.badge>
58
          </:col>
59
          <:col :let={entry} label="Summary">
60
            {entry.summary}
61
          </:col>
62
        </.table>
63
      </main>
64
    </Layouts.app>
65
    """
66
  end
67
68
  defp modality_label(modality) do
69
    modality
70
    |> to_string()
71
    |> String.capitalize()
72
  end
73
74
  defp kind_label(kind) do
75
    kind
76
    |> to_string()
77
    |> String.replace("_", " ")
78
    |> String.capitalize()
79
  end
80
81
  defp kind_variant(:turn), do: :info
82
  defp kind_variant(:tool_step), do: :warning
83
  defp kind_variant(:decision), do: :success
84
  defp kind_variant(:system), do: :default
85
  defp kind_variant(_kind), do: :default
86
end
lib/openagents_web/route_authority.ex modified +2

@@ -33,6 +33,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

33 33
34 34
  @authenticated_browser_prefixes [
35 35
    "/sarah",
36
    "/timeline",
36 37
    "/computers",
37 38
    "/voice/",
38 39
    "/data",

@@ -699,6 +700,7 @@ defmodule OpenAgentsWeb.RouteAuthority do

699 700
  defp browser_scope("/voice/" <> _path), do: "voice:self"
700 701
  defp browser_scope("/data" <> _path), do: "data:self"
701 702
  defp browser_scope("/artifact-catalog"), do: "artifact-catalog:read"
703
  defp browser_scope("/timeline"), do: "timeline:self"
702 704
  defp browser_scope("/memory/" <> _path), do: "memory:self"
703 705
  defp browser_scope("/github/connection"), do: "github-tools:self"
704 706
  defp browser_scope("/notifications"), do: "notification:self"
lib/openagents_web/router.ex modified +1

@@ -213,6 +213,7 @@ defmodule OpenAgentsWeb.Router do

213 213
      on_mount: [{OpenAgentsWeb.UserAuth, :ensure_authenticated}] do
214 214
      live "/sarah", ChatLive, :index
215 215
      live "/memory", MemoryLive, :index
216
      live "/timeline", TimelineLive, :index
216 217
      live "/computers", ComputersLive, :index
217 218
      live "/artifact-catalog", ArtifactCatalogLive, :index
218 219
      live "/notifications", NotificationsLive, :index
test/openagents_web/live/timeline_live_test.exs added +58

@@ -0,0 +1,58 @@

1
defmodule OpenAgentsWeb.TimelineLiveTest do
2
  @moduledoc """
3
  `/timeline` renders the current account's unified activity, newest first,
4
  and never shows another account's records.
5
  """
6
7
  use OpenAgentsWeb.ConnCase, async: false
8
9
  import Phoenix.LiveViewTest
10
11
  alias OpenAgents.{Conversations, Repo, Threads}
12
13
  test "renders newest-first entries from more than one modality and excludes other accounts", %{
14
    conn: conn
15
  } do
16
    me = github_user("timeline-live-me")
17
    other = github_user("timeline-live-other")
18
19
    # An older coder entry.
20
    {:ok, my_thread} = Threads.open(me, "My timeline thread")
21
    [opened_event] = Threads.list_events(my_thread)
22
23
    old_time = DateTime.from_naive!(~N[2026-01-01 10:00:00.000000], "Etc/UTC")
24
25
    opened_event
26
    |> Ecto.Changeset.change(emitted_at: old_time)
27
    |> Repo.update!()
28
29
    # A newer chat entry.
30
    {:ok, conversation} = Conversations.ensure_conversation(me)
31
    {:ok, %{turn: turn}} = Conversations.create_turn(conversation, "Chat message for timeline")
32
33
    # Another account's entry must not appear.
34
    {:ok, other_thread} = Threads.open(other, "Other private thread")
35
    [other_event] = Threads.list_events(other_thread)
36
37
    conn = log_in_github_user(conn, "timeline-live-me")
38
    assert {:ok, view, _html} = live(conn, ~p"/timeline")
39
40
    html = render(view)
41
42
    assert html =~ "Chat message for timeline"
43
    assert html =~ "Thread opened"
44
    refute html =~ ~s(id="timeline-entry-#{other_event.id}")
45
46
    assert has_element?(view, "#timeline-entry-#{turn.id}")
47
    assert has_element?(view, "#timeline-entry-#{opened_event.id}")
48
49
    # Newest-first: the chat row appears before the older thread row.
50
    {chat_pos, _} = :binary.match(html, ~s(id="timeline-entry-#{turn.id}"))
51
    {coder_pos, _} = :binary.match(html, ~s(id="timeline-entry-#{opened_event.id}"))
52
    assert chat_pos < coder_pos
53
  end
54
55
  test "anonymous browser is redirected without revealing the timeline", %{conn: conn} do
56
    assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/timeline")
57
  end
58
end

This page updates live while a promote is in flight · changelog