Patch between component pages, and add an SCV stream composition

f0c12794bf0e · AtlantisPleb · · parent 6b889c6f8637

Patch between component pages, and add an SCV stream composition

Navigating between component pages threw the sidebar away and scrolled it
back to the top on every click, which makes a long catalog tedious to read:
you lose your place each time you look at anything.

/components and /components/:slug are the same LiveView, so moving between
them is a patch. The DOM is diffed, each row's selected state updates in
place, and the sidebar keeps its scroll position. The index cards patch for
the same reason.

It cannot be an unconditional patch: /components/icons is a different
LiveView, and a patch can neither reach it nor leave it. sidebar_link/1
takes a `patchable` flag and falls back to a navigate that remounts on
purpose, so the one case that must remount still does.

Adds scv_streams/1. The swarm answers "how is the fleet"; this answers
"what is it doing" -- a status ring can say an SCV is running, but not that
it has been rewriting the same test for four minutes.

Rows are a fixed height so a chatty agent cannot push the others off
screen: fifteen rows that stay put are readable, fifteen that reflow are
not. The tail clips from the left rather than the right, so the newest text
stays visible as the line grows. The stream is a bounded tail by
construction, never the whole transcript -- scv_steps records every
provider and tool boundary, and rendering all of it would make a busy fleet
unreadable and expensive at once.

The catalog test caught sidebar_link/1 shipping without a page, so it is
catalogued rather than exempted: it is a real reusable primitive, and its
demo is where the patch-versus-navigate rule is written down.

mix precommit green: 1416 tests, 17 JS.

Deploy story

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

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/graph.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/components.html.heex
  • modified lib/openagents_web/live/components_live.ex

Diff

6 files changed, +434 -30

assets/css/openagents.css modified +126

@@ -1949,3 +1949,129 @@

1949 1949
    color: var(--success);
1950 1950
  }
1951 1951
}
1952
1953
/* ── SCV streams ──────────────────────────────────────────────────────────── */
1954
1955
/* One row per agent: glyph, identity, and the tail of its output. Rows are a
1956
 * fixed height because a chatty agent must not push the others off screen --
1957
 * fifteen rows that stay put are readable, fifteen that reflow are not. */
1958
1959
@layer components {
1960
  .scv-streams {
1961
    display: flex;
1962
    flex-direction: column;
1963
    gap: 1px;
1964
    margin: 0;
1965
    padding: 0;
1966
    list-style: none;
1967
    border: 1px solid var(--line);
1968
    border-radius: var(--radius-md);
1969
    background: var(--line-faint);
1970
    overflow: hidden;
1971
  }
1972
1973
  .scv-stream {
1974
    display: flex;
1975
    align-items: center;
1976
    gap: 10px;
1977
    height: 44px;
1978
    padding-inline: 8px;
1979
    background: var(--ink-surface);
1980
  }
1981
1982
  .scv-stream[data-selected="true"] {
1983
    background: var(--ink-hover);
1984
  }
1985
1986
  .scv-stream__glyph {
1987
    flex: none;
1988
    width: 30px;
1989
    height: 30px;
1990
  }
1991
1992
  .scv-stream__body {
1993
    display: flex;
1994
    flex-direction: column;
1995
    justify-content: center;
1996
    gap: 1px;
1997
    min-width: 0;
1998
    flex: 1;
1999
  }
2000
2001
  .scv-stream__meta {
2002
    display: flex;
2003
    align-items: baseline;
2004
    gap: 8px;
2005
    font-size: 0.6875rem;
2006
    line-height: 1;
2007
  }
2008
2009
  .scv-stream__id {
2010
    color: var(--text-body);
2011
    font-family: var(--font-mono);
2012
  }
2013
2014
  .scv-stream__tool {
2015
    color: var(--text-dim);
2016
    font-family: var(--font-mono);
2017
  }
2018
2019
  .scv-stream__status {
2020
    margin-left: auto;
2021
    color: var(--text-dim);
2022
    text-transform: uppercase;
2023
    letter-spacing: 0.06em;
2024
  }
2025
2026
  .scv-stream[data-status="circuit_open"] .scv-stream__status {
2027
    color: var(--danger);
2028
  }
2029
2030
  .scv-stream[data-status="running"] .scv-stream__status {
2031
    color: var(--accent-bright);
2032
  }
2033
2034
  /* The tail is one line and clips from the left, so the newest text -- the end
2035
     of the line -- is the part that stays visible as it grows. */
2036
  .scv-stream__text {
2037
    margin: 0;
2038
    overflow: hidden;
2039
    color: var(--text-muted);
2040
    direction: rtl;
2041
    font-family: var(--font-mono);
2042
    font-size: 0.75rem;
2043
    line-height: 1.25;
2044
    text-align: left;
2045
    text-overflow: ellipsis;
2046
    white-space: nowrap;
2047
    unicode-bidi: plaintext;
2048
  }
2049
2050
  .scv-stream__caret {
2051
    display: inline-block;
2052
    width: 6px;
2053
    height: 12px;
2054
    margin-left: 2px;
2055
    background: var(--accent);
2056
    vertical-align: text-bottom;
2057
    animation: scv-stream-caret 1.1s steps(2, start) infinite;
2058
  }
2059
2060
  @keyframes scv-stream-caret {
2061
    0%,
2062
    50% {
2063
      opacity: 1;
2064
    }
2065
    50.01%,
2066
    100% {
2067
      opacity: 0;
2068
    }
2069
  }
2070
2071
  /* A blinking caret is motion for its own sake if the reader asked for less. */
2072
  @media (prefers-reduced-motion: reduce) {
2073
    .scv-stream__caret {
2074
      animation: none;
2075
    }
2076
  }
2077
}
lib/openagents_web/component_catalog.ex modified +14

@@ -189,6 +189,13 @@ defmodule OpenAgentsWeb.ComponentCatalog do

189 189
    %{
190 190
      title: "Layout",
191 191
      items: [
192
        %{
193
          slug: "sidebar-link",
194
          title: "Sidebar link",
195
          icon: "menu",
196
          source: "OpenAgentsWeb.Layouts.sidebar_link/1",
197
          summary: "A navigation row that patches within a LiveView and navigates out of it."
198
        },
192 199
        %{
193 200
          slug: "command-bar",
194 201
          title: "Command bar",

@@ -223,6 +230,13 @@ defmodule OpenAgentsWeb.ComponentCatalog do

223 230
          source: "OpenAgentsWeb.UI.Graph.graph_link/1",
224 231
          summary: "Surface-anchored link with a shape-conforming termination and a step pulse."
225 232
        },
233
        %{
234
          slug: "scv-streams",
235
          title: "SCV streams",
236
          icon: "text",
237
          source: "OpenAgentsWeb.UI.Graph.scv_streams/1",
238
          summary: "Each agent beside the tail of what it is currently saying."
239
        },
226 240
        %{
227 241
          slug: "scv-swarm",
228 242
          title: "SCV swarm",
lib/openagents_web/components/graph.ex modified +65

@@ -298,6 +298,71 @@ defmodule OpenAgentsWeb.UI.Graph do

298 298
    """
299 299
  end
300 300
301
  @doc """
302
  SCVs beside what each one is currently saying.
303
304
  The swarm answers "how is the fleet", this answers "what is it doing". A
305
  status ring tells you an SCV is running; it cannot tell you it has been
306
  rewriting the same test for four minutes. The stream can.
307
308
  Each row is one SCV: its node, its identity, and the tail of its output. Rows
309
  keep a fixed height so a chatty agent cannot push the others off screen —
310
  fifteen rows that stay put are readable, fifteen that reflow are not. The
311
  stream is a bounded tail, never the whole transcript: `scv_steps` is specified
312
  to record every provider and tool boundary, and rendering all of it would make
313
  a busy fleet unreadable and expensive at once.
314
315
  Each entry is a map with `:id`, `:status`, and optionally `:label`,
316
  `:weight`, `:tool` (the tool boundary currently open) and `:text` (the tail).
317
  """
318
  attr :id, :string, default: "scv-streams"
319
  attr :scvs, :list, required: true
320
  attr :selected_id, :string, default: nil
321
  attr :class, :any, default: nil
322
323
  def scv_streams(assigns) do
324
    ~H"""
325
    <ul id={@id} class={["scv-streams", @class]} role="list">
326
      <li
327
        :for={scv <- @scvs}
328
        class="scv-stream"
329
        data-status={scv.status}
330
        data-selected={scv.id == @selected_id}
331
      >
332
        <.graph_surface
333
          view_box="0 0 44 44"
334
          label={"#{Map.get(scv, :label, scv.id)}, #{scv.status}"}
335
          prefix={"#{@id}-#{scv.id}"}
336
          class="scv-stream__glyph"
337
        >
338
          <.graph_node
339
            id={"#{@id}-#{scv.id}-node"}
340
            x={22.0}
341
            y={22.0}
342
            r={13.0 + Map.get(scv, :weight, 0.0) * 5.0}
343
            status={scv.status}
344
          />
345
        </.graph_surface>
346
347
        <div class="scv-stream__body">
348
          <div class="scv-stream__meta">
349
            <span class="scv-stream__id">{Map.get(scv, :label, scv.id)}</span>
350
            <span :if={scv[:tool]} class="scv-stream__tool">{scv[:tool]}</span>
351
            <span class="scv-stream__status">{scv.status}</span>
352
          </div>
353
          <p class="scv-stream__text">
354
            {scv[:text]}<span
355
              :if={scv.status == :running}
356
              class="scv-stream__caret"
357
              aria-hidden="true"
358
            ></span>
359
          </p>
360
        </div>
361
      </li>
362
    </ul>
363
    """
364
  end
365
301 366
  @doc """
302 367
  A swarm of SCVs in a deterministic staged layout.
303 368
lib/openagents_web/components/layouts.ex modified +44

@@ -135,6 +135,50 @@ defmodule OpenAgentsWeb.Layouts do

135 135
    """
136 136
  end
137 137
138
  @doc """
139
  A sidebar row that navigates without throwing the sidebar away.
140
141
  `/components` and `/components/:slug` are the same LiveView, so moving
142
  between them is a patch: the DOM is diffed, the row's selected state updates
143
  in place, and the sidebar keeps its scroll position. `navigate` would remount
144
  and scroll the list back to the top on every click.
145
146
  `/components/icons` is a different LiveView, so a patch cannot reach it and a
147
  patch cannot leave it. `patchable` says whether the currently mounted view is
148
  the one that owns these params; when it is not, the row falls back to a
149
  navigate that remounts on purpose.
150
  """
151
  attr :path, :string, required: true
152
  attr :label, :string, required: true
153
  attr :icon, :string, required: true
154
  attr :selected, :boolean, default: false
155
  attr :patchable, :boolean, default: false
156
157
  def sidebar_link(assigns) do
158
    ~H"""
159
    <div class="sidebar-row" data-selected={@selected}>
160
      <.link
161
        :if={@patchable}
162
        patch={@path}
163
        class="sidebar-row__hit"
164
        aria-label={@label}
165
        aria-current={@selected && "page"}
166
      ></.link>
167
      <.link
168
        :if={!@patchable}
169
        navigate={@path}
170
        class="sidebar-row__hit"
171
        aria-label={@label}
172
        aria-current={@selected && "page"}
173
      ></.link>
174
      <span class="sidebar-row__content">
175
        <span class="sidebar-row__icon"><UI.icon name={@icon} /></span>
176
        <span class="sidebar-row__label">{@label}</span>
177
      </span>
178
    </div>
179
    """
180
  end
181
138 182
  @doc """
139 183
  One control that flips between light and dark.
140 184
lib/openagents_web/components/layouts/components.html.heex modified +22 -28

@@ -1,3 +1,4 @@

1
<% patchable? = @active_component != :icons %>
1 2
<div class="docs-layout">
2 3
  <aside class="docs-sidebar">
3 4
    <nav class="flex flex-col gap-1" aria-label="Components back">

@@ -13,13 +14,13 @@

13 14
    <nav class="docs-sidebar__nav" aria-label="Component library">
14 15
      <section class="docs-sidebar__section">
15 16
        <h3 class="sidebar-section-label">Overview</h3>
16
        <div class="sidebar-row" data-selected={@active_component == :index}>
17
          <.link navigate={~p"/components"} class="sidebar-row__hit" aria-label="All components"></.link>
18
          <span class="sidebar-row__content">
19
            <span class="sidebar-row__icon"><.icon name="book" /></span>
20
            <span class="sidebar-row__label">All components</span>
21
          </span>
22
        </div>
17
        <Layouts.sidebar_link
18
          path={~p"/components"}
19
          label="All components"
20
          icon="book"
21
          selected={@active_component == :index}
22
          patchable={patchable?}
23
        />
23 24
      </section>
24 25
25 26
      <section

@@ -27,32 +28,25 @@

27 28
        class="docs-sidebar__section"
28 29
      >
29 30
        <h3 class="sidebar-section-label">{section.title}</h3>
30
        <div
31
        <Layouts.sidebar_link
31 32
          :for={item <- section.items}
32
          class="sidebar-row"
33
          data-selected={@active_component == item.slug}
34
        >
35
          <.link
36
            navigate={~p"/components/#{item.slug}"}
37
            class="sidebar-row__hit"
38
            aria-label={item.title}
39
          ></.link>
40
          <span class="sidebar-row__content">
41
            <span class="sidebar-row__icon"><.icon name={item.icon} /></span>
42
            <span class="sidebar-row__label">{item.title}</span>
43
          </span>
44
        </div>
33
          path={~p"/components/#{item.slug}"}
34
          label={item.title}
35
          icon={item.icon}
36
          selected={@active_component == item.slug}
37
          patchable={patchable?}
38
        />
45 39
      </section>
46 40
47 41
      <section class="docs-sidebar__section">
48 42
        <h3 class="sidebar-section-label">Reference</h3>
49
        <div class="sidebar-row" data-selected={@active_component == :icons}>
50
          <.link navigate={~p"/components/icons"} class="sidebar-row__hit" aria-label="Icons"></.link>
51
          <span class="sidebar-row__content">
52
            <span class="sidebar-row__icon"><.icon name="book-open" /></span>
53
            <span class="sidebar-row__label">Icons</span>
54
          </span>
55
        </div>
43
        <Layouts.sidebar_link
44
          path={~p"/components/icons"}
45
          label="Icons"
46
          icon="book-open"
47
          selected={@active_component == :icons}
48
          patchable={false}
49
        />
56 50
      </section>
57 51
    </nav>
58 52
  </aside>
lib/openagents_web/live/components_live.ex modified +163 -2

@@ -53,6 +53,126 @@ defmodule OpenAgentsWeb.ComponentsLive do

53 53
    %{id: "scv-15", status: :running, label: "15", weight: 0.8, item_status: :discovered}
54 54
  ]
55 55
56
  # The same fifteen agents as the swarm, with the tail each is emitting. Text
57
  # is representative of real OpenCode output rather than lorem, so the row
58
  # width is exercised the way it will be in use.
59
  @demo_streams [
60
    %{
61
      id: "scv-01",
62
      label: "scv-01",
63
      status: :running,
64
      weight: 0.9,
65
      tool: "edit",
66
      text: "applying patch to lib/openagents/forge/hot_loader.ex (3 hunks)"
67
    },
68
    %{
69
      id: "scv-02",
70
      label: "scv-02",
71
      status: :running,
72
      weight: 0.3,
73
      tool: "bash",
74
      text: "mix test test/openagents/forge/targets_test.exs --seed 0"
75
    },
76
    %{
77
      id: "scv-03",
78
      label: "scv-03",
79
      status: :idle,
80
      weight: 0.0,
81
      text: "waiting for admitted work"
82
    },
83
    %{
84
      id: "scv-04",
85
      label: "scv-04",
86
      status: :running,
87
      weight: 0.6,
88
      tool: "read",
89
      text: "reading docs/scv-planning.md to bound the objective"
90
    },
91
    %{
92
      id: "scv-05",
93
      label: "scv-05",
94
      status: :paused,
95
      weight: 0.4,
96
      tool: "edit",
97
      text: "paused: budget window exhausted, resumes at the next window"
98
    },
99
    %{
100
      id: "scv-06",
101
      label: "scv-06",
102
      status: :idle,
103
      weight: 0.0,
104
      text: "waiting for admitted work"
105
    },
106
    %{
107
      id: "scv-07",
108
      label: "scv-07",
109
      status: :circuit_open,
110
      weight: 0.2,
111
      text: "circuit open after 3 consecutive provider timeouts"
112
    },
113
    %{
114
      id: "scv-08",
115
      label: "scv-08",
116
      status: :running,
117
      weight: 1.0,
118
      tool: "grep",
119
      text: "searching for remaining callers of Sarah.Forge.Targets.promote/4"
120
    },
121
    %{
122
      id: "scv-09",
123
      label: "scv-09",
124
      status: :disabled,
125
      weight: 0.0,
126
      text: "disabled by operator"
127
    },
128
    %{
129
      id: "scv-10",
130
      label: "scv-10",
131
      status: :running,
132
      weight: 0.5,
133
      tool: "bash",
134
      text: "mix precommit -- 1416 passed, 14 excluded"
135
    },
136
    %{
137
      id: "scv-11",
138
      label: "scv-11",
139
      status: :idle,
140
      weight: 0.0,
141
      text: "waiting for admitted work"
142
    },
143
    %{
144
      id: "scv-12",
145
      label: "scv-12",
146
      status: :paused,
147
      weight: 0.7,
148
      tool: "read",
149
      text: "paused: awaiting human promotion under SELF-EDIT-001"
150
    },
151
    %{
152
      id: "scv-13",
153
      label: "scv-13",
154
      status: :running,
155
      weight: 0.15,
156
      tool: "edit",
157
      text: "reverting: regression test still red after the third attempt"
158
    },
159
    %{
160
      id: "scv-14",
161
      label: "scv-14",
162
      status: :disabled,
163
      weight: 0.0,
164
      text: "disabled by operator"
165
    },
166
    %{
167
      id: "scv-15",
168
      label: "scv-15",
169
      status: :running,
170
      weight: 0.8,
171
      tool: "write",
172
      text: "writing test/openagents_web/components/graph_test.exs"
173
    }
174
  ]
175
56 176
  @impl true
57 177
  def mount(_params, _session, socket) do
58 178
    form =

@@ -72,7 +192,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

72 192
     |> assign(:rows, @sample_rows)
73 193
     |> assign(:openagents_icons, @openagents_icons)
74 194
     |> assign(:demo_user, @demo_user)
75
     |> assign(:demo_swarm, @demo_swarm)}
195
     |> assign(:demo_swarm, @demo_swarm)
196
     |> assign(:demo_streams, @demo_streams)}
76 197
  end
77 198
78 199
  @impl true

@@ -131,7 +252,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

131 252
        <div class="grid gap-4 sm:grid-cols-2">
132 253
          <.link
133 254
            :for={item <- section.items}
134
            navigate={~p"/components/#{item.slug}"}
255
            patch={~p"/components/#{item.slug}"}
135 256
            class="card bg-card border border-border p-4 hover:border-foreground/30"
136 257
          >
137 258
            <h3 class="text-lg font-medium mb-1">{item.title}</h3>

@@ -495,6 +616,29 @@ defmodule OpenAgentsWeb.ComponentsLive do

495 616
496 617
  # --- Layout ---------------------------------------------------------------
497 618
619
  defp component_demo(%{item: %{slug: "sidebar-link"}} = assigns) do
620
    ~H"""
621
    <div class="space-y-3">
622
      <p class="text-sm text-base-content/60">
623
        The row that builds this page's own sidebar. A selected row carries <code>aria-current="page"</code>, so the current place is announced rather
624
        than only shaded.
625
      </p>
626
      <div class="docs-sidebar__section max-w-xs">
627
        <Layouts.sidebar_link path="#" label="Not selected" icon="book" patchable={false} />
628
        <Layouts.sidebar_link path="#" label="Selected" icon="cube" selected patchable={false} />
629
        <Layouts.sidebar_link path="#" label="Another row" icon="grid" patchable={false} />
630
      </div>
631
      <p class="text-sm text-base-content/60">
632
        <code>patchable</code>
633
        decides whether the row patches or navigates. Patching keeps the sidebar's DOM
634
        and therefore its scroll position; navigating remounts and sends the list back
635
        to the top. A row may patch only when the mounted LiveView already owns the
636
        target params.
637
      </p>
638
    </div>
639
    """
640
  end
641
498 642
  defp component_demo(%{item: %{slug: "command-bar"}} = assigns) do
499 643
    ~H"""
500 644
    <Layouts.command_bar aria_label="Demo command bar" current_user={@demo_user}>

@@ -681,6 +825,23 @@ defmodule OpenAgentsWeb.ComponentsLive do

681 825
    """
682 826
  end
683 827
828
  defp component_demo(%{item: %{slug: "scv-streams"}} = assigns) do
829
    ~H"""
830
    <div class="space-y-3">
831
      <p class="text-sm text-base-content/60">
832
        The swarm answers "how is the fleet"; this answers "what is it doing". A
833
        status ring can tell you an agent is running, but not that it has been
834
        rewriting the same test for four minutes.
835
      </p>
836
      <Graph.scv_streams scvs={@demo_streams} selected_id="scv-04" />
837
      <p class="text-sm text-base-content/60">
838
        Rows are a fixed height and the tail clips from the left, so a chatty agent
839
        cannot push the others off screen and the newest text stays visible.
840
      </p>
841
    </div>
842
    """
843
  end
844
684 845
  defp component_demo(%{item: %{slug: "scv-swarm"}} = assigns) do
685 846
    ~H"""
686 847
    <div class="space-y-3">

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