Preview a delegation's latest activity in three lines

3a36f1b053ad · AtlantisPleb · · parent 01e1a8c2fdb8

Preview a delegation's latest activity in three lines

The live delegation panel stated the activity only inside its tall
rolling log, so what the delegated agent was doing right now sat at the
bottom of the scroll. The panel now carries an activity line directly
under the delegate line, mirrored from the newest preview row by the
log hook, and the log itself is bounded to exactly three visible lines:
one line per thing the subagent did, autoscrolling to the newest as
chunks arrive, long lines clipping instead of wrapping.

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 316 · 2026-08-24T22:04:51.029406Z

Changed files

  • modified assets/css/openagents.css
  • modified lib/openagents_web/live/chat_live.ex
  • modified test/openagents_web/live/chat_delegation_rail_test.exs

Diff

3 files changed, +177 -136

assets/css/openagents.css modified +38 -74

@@ -3355,11 +3355,11 @@

3355 3355
    font-variant-numeric: tabular-nums;
3356 3356
  }
3357 3357
3358
  /* The preview is bounded to exactly three visible lines and scrolls inside
3359
     that budget; the .DelegationLog hook autoscrolls it to the newest row as
3360
     activity arrives. */
3358 3361
  .delegation-log {
3359
    display: flex;
3360
    max-height: 40vh;
3361
    flex-direction: column;
3362
    gap: 6px;
3362
    max-height: calc(3 * 1.5em);
3363 3363
    overflow-y: auto;
3364 3364
    overscroll-behavior: contain;
3365 3365
    color: var(--text-muted);

@@ -3368,48 +3368,33 @@

3368 3368
    line-height: 1.5;
3369 3369
  }
3370 3370
3371
  /* The log only ever scrolls vertically. Its rows are flex items, which are
3372
     `min-width: auto` by default, so one long command or path made the whole
3373
     column as wide as itself and the panel scrolled sideways instead. */
3374
  .delegation-log > * {
3375
    min-width: 0;
3376
    max-width: 100%;
3377
  }
3378
3379
  /* ── Streamed log rows ───────────────────────────────────────────────────── */
3371
  /* ── Preview rows ────────────────────────────────────────────────────────── */
3380 3372
3381 3373
  /* Built by the .DelegationLog hook, which frames the plain text stream into
3382
     prose, tool cards and notes. The hook owns the DOM; these are the only
3383
     rules it relies on. */
3374
     one-line rows and mirrors the newest into the activity line. The hook owns
3375
     the DOM; these are the only rules it relies on. One activity is one line,
3376
     so long commands and paths clip instead of spending a second line. */
3384 3377
3385
  .deleg-prose {
3386
    overflow-wrap: anywhere;
3387
    /* The stream carries its own line breaks, and they are the shape of what
3388
       the agent said; the rest wraps to the rail's width. */
3389
    white-space: pre-wrap;
3378
  .deleg-row {
3379
    min-width: 0;
3380
    max-width: 100%;
3381
    white-space: nowrap;
3390 3382
  }
3391 3383
3392
  .deleg-tool {
3393
    border: 1px solid var(--line);
3394
    border-radius: var(--radius-sm);
3395
    background: var(--ink-raised);
3384
  .deleg-row--prose,
3385
  .deleg-row--note {
3386
    overflow: hidden;
3387
    text-overflow: ellipsis;
3388
    white-space: nowrap;
3396 3389
  }
3397 3390
3398
  .deleg-tool__summary {
3391
  .deleg-row--tool {
3399 3392
    display: flex;
3400 3393
    align-items: center;
3401
    gap: 8px;
3402
    padding: 5px 8px;
3403
    color: var(--text-muted);
3404
    cursor: pointer;
3405
    list-style: none;
3406
  }
3407
3408
  .deleg-tool__summary::-webkit-details-marker {
3409
    display: none;
3394
    gap: 6px;
3410 3395
  }
3411 3396
3412
  .deleg-tool__dot {
3397
  .deleg-row__dot {
3413 3398
    width: 6px;
3414 3399
    height: 6px;
3415 3400
    flex: none;

@@ -3417,67 +3402,46 @@

3417 3402
    background: var(--info);
3418 3403
  }
3419 3404
3420
  .deleg-tool[data-status="succeeded"] .deleg-tool__dot {
3405
  .deleg-row[data-status="succeeded"] .deleg-row__dot {
3421 3406
    background: var(--success);
3422 3407
  }
3423 3408
3424
  .deleg-tool[data-status="failed"] .deleg-tool__dot {
3409
  .deleg-row[data-status="failed"] .deleg-row__dot {
3425 3410
    background: var(--danger);
3426 3411
  }
3427 3412
3428
  .deleg-tool__label {
3413
  .deleg-row__label {
3429 3414
    flex: none;
3430 3415
    color: var(--text-body);
3431 3416
  }
3432 3417
3433
  /* The command as a one-line preview beside its label. It is the detail that
3434
     tells two `Terminal` steps apart, so it is stated on the closed card and
3435
     truncated rather than wrapped. */
3436
  .deleg-tool__inline {
3418
  .deleg-row__cmd {
3437 3419
    min-width: 0;
3438 3420
    flex: 1;
3439 3421
    overflow: hidden;
3440 3422
    color: var(--text-dim);
3441 3423
    text-overflow: ellipsis;
3442
    white-space: nowrap;
3443 3424
  }
3444 3425
3445
  .deleg-tool__body {
3446
    display: flex;
3447
    flex-direction: column;
3448
    gap: 6px;
3449
    border-top: 1px solid var(--line);
3450
    padding: 8px;
3426
  .deleg-row--note[data-tone="warn"] {
3427
    color: var(--warning);
3451 3428
  }
3452 3429
3453
  .deleg-tool__cmd,
3454
  .deleg-tool__out {
3455
    max-height: 16rem;
3456
    overflow: auto;
3457
    overflow-wrap: anywhere;
3458
    font: inherit;
3459
    white-space: pre-wrap;
3430
  .deleg-row--note[data-tone="error"] {
3431
    color: var(--danger);
3460 3432
  }
3461 3433
3462
  .deleg-tool__cmd {
3434
  /* The activity the delegation is doing right now, stated directly under the
3435
     delegate line instead of only wherever the preview happens to be scrolled.
3436
     The hook mirrors the newest preview row into it. */
3437
  .delegation-activity {
3438
    overflow: hidden;
3463 3439
    color: var(--text-body);
3464
  }
3465
3466
  .deleg-tool__out {
3467
    color: var(--text-muted);
3468
  }
3469
3470
  .deleg-note {
3471
    overflow-wrap: anywhere;
3472
    color: var(--text-dim);
3473
  }
3474
3475
  .deleg-note[data-tone="warn"] {
3476
    color: var(--warning);
3477
  }
3478
3479
  .deleg-note[data-tone="error"] {
3480
    color: var(--danger);
3440
    font-family: var(--font-mono, ui-monospace, monospace);
3441
    font-size: 0.75rem;
3442
    line-height: 1.5;
3443
    text-overflow: ellipsis;
3444
    white-space: nowrap;
3481 3445
  }
3482 3446
3483 3447
  .delegation-truncated {
lib/openagents_web/live/chat_live.ex modified +97 -57

@@ -1114,11 +1114,18 @@ defmodule OpenAgentsWeb.ChatLive do

1114 1114
        // Record/unit separators frame structured tool events inside the plain
1115 1115
        // text stream (see sarah-computer-controller AcpAgent.renderUpdate).
1116 1116
        // A frame is one line beginning with RS whose fields split on US;
1117
        // everything else is agent prose. This hook renders frames as
1118
        // collapsible tool cards and notes, and prose as text — never HTML.
1117
        // everything else is agent prose. This hook renders the stream as the
1118
        // panel's preview -- one row per thing the delegated agent did -- and
1119
        // mirrors the newest row into the sibling activity line, so what is
1120
        // happening right now reads true wherever the preview is scrolled.
1121
        // Rows and the mirror are text, never HTML.
1119 1122
        const RS = String.fromCharCode(30)
1120 1123
        const US = String.fromCharCode(31)
1121 1124
1125
        // The server caps the stream at 64 KiB; this bounds the element count
1126
        // too, so pathological tiny chunks cannot pile up rows instead.
1127
        const MAX_ROWS = 200
1128
1122 1129
        const decode64 = (value) => {
1123 1130
          try {
1124 1131
            return new TextDecoder().decode(Uint8Array.from(atob(value), (c) => c.charCodeAt(0)))

@@ -1131,7 +1138,8 @@ defmodule OpenAgentsWeb.ChatLive do

1131 1138
          mounted() {
1132 1139
            this.buffer = ""
1133 1140
            this.tools = new Map()
1134
            this.prose = null
1141
            this.proseRow = null
1142
            this.activityLine = this.el.parentElement.querySelector("[data-delegation-status]")
1135 1143
            this.handleEvent("delegation:chunk", ({ ref, text }) => {
1136 1144
              if (this.el.dataset.ref !== ref) return
1137 1145
              const follow =

@@ -1160,81 +1168,70 @@ defmodule OpenAgentsWeb.ChatLive do

1160 1168
            }
1161 1169
          },
1162 1170
1171
          // Prose arrives in chunks that can split a line: the open row grows
1172
          // until a newline or a frame closes it, and each committed line is
1173
          // one row -- the preview states one thing per line.
1163 1174
          appendProse(text) {
1164
            if (text === "") return
1165
            if (!this.prose || this.prose.parentNode !== this.el || this.el.lastChild !== this.prose) {
1166
              this.prose = document.createElement("span")
1167
              this.prose.className = "deleg-prose"
1168
              this.el.appendChild(this.prose)
1175
            const lines = text.split("\n")
1176
            for (const [index, piece] of lines.entries()) {
1177
              if (index > 0) this.proseRow = null
1178
              if (piece === "") continue
1179
              if (!this.proseRow || this.proseRow.parentNode !== this.el) {
1180
                this.proseRow = this.addRow("deleg-row deleg-row--prose")
1181
              }
1182
              this.setRow(this.proseRow, this.proseRow.textContent + piece)
1169 1183
            }
1170
            this.prose.appendChild(document.createTextNode(text))
1171 1184
          },
1172 1185
1173 1186
          renderFrame(line) {
1187
            this.proseRow = null
1174 1188
            const fields = line.split(US)
1175 1189
            if (fields[0] === "T") {
1176 1190
              this.renderTool(fields)
1177 1191
            } else if (fields[0] === "N") {
1178 1192
              this.renderNote(fields)
1179 1193
            }
1180
            this.prose = null
1181 1194
          },
1182 1195
1183 1196
          // T | id | phase(0 start,1 done,2 failed) | kind | b64 title | b64 detail
1197
          // Start carries the command; completion carries output, which the
1198
          // durable transcript owns -- here a finished step just recolors its
1199
          // dot on the same single line.
1184 1200
          renderTool([, id, phase, kind, b64title, b64detail]) {
1185 1201
            const title = decode64(b64title || "")
1186
            const detail = decode64(b64detail || "")
1187
            let card = this.tools.get(id)
1188
            if (!card) {
1189
              card = this.buildTool(id, kind, title, detail)
1190
              this.tools.set(id, card)
1191
              this.el.appendChild(card.root)
1202
            const command = decode64(b64detail || "")
1203
            let row = this.tools.get(id)
1204
            if (!row) {
1205
              row = this.buildToolRow(kind, title, command)
1206
              this.tools.set(id, row)
1192 1207
            }
1193 1208
            if (phase === "1" || phase === "2") {
1194
              card.root.dataset.status = phase === "1" ? "succeeded" : "failed"
1195
              if (detail !== "") {
1196
                card.out.textContent = detail
1197
                card.out.hidden = false
1198
              }
1209
              row.dataset.status = phase === "1" ? "succeeded" : "failed"
1199 1210
            }
1200 1211
          },
1201 1212
1202
          buildTool(id, kind, title, command) {
1203
            const root = document.createElement("details")
1204
            root.className = "deleg-tool"
1205
            root.dataset.status = "running"
1206
            root.dataset.kind = kind || "other"
1213
          buildToolRow(kind, title, command) {
1214
            const row = document.createElement("div")
1215
            row.className = "deleg-row deleg-row--tool"
1216
            row.dataset.status = "running"
1207 1217
1208
            const summary = document.createElement("summary")
1209
            summary.className = "deleg-tool__summary"
1210 1218
            const dot = document.createElement("span")
1211
            dot.className = "deleg-tool__dot"
1219
            dot.className = "deleg-row__dot"
1212 1220
            const label = document.createElement("span")
1213
            label.className = "deleg-tool__label"
1221
            label.className = "deleg-row__label"
1214 1222
            label.textContent = this.kindLabel(kind) || title || "tool"
1215
            summary.appendChild(dot)
1216
            summary.appendChild(label)
1223
            row.appendChild(dot)
1224
            row.appendChild(label)
1217 1225
            if (command !== "") {
1218
              const inline = document.createElement("code")
1219
              inline.className = "deleg-tool__inline"
1220
              inline.textContent = command
1221
              summary.appendChild(inline)
1226
              const cmd = document.createElement("span")
1227
              cmd.className = "deleg-row__cmd"
1228
              cmd.textContent = command
1229
              row.appendChild(cmd)
1222 1230
            }
1223 1231
1224
            const body = document.createElement("div")
1225
            body.className = "deleg-tool__body"
1226
            const cmd = document.createElement("pre")
1227
            cmd.className = "deleg-tool__cmd"
1228
            cmd.textContent = command !== "" ? "$ " + command : title
1229
            const out = document.createElement("pre")
1230
            out.className = "deleg-tool__out"
1231
            out.hidden = true
1232
            body.appendChild(cmd)
1233
            body.appendChild(out)
1234
1235
            root.appendChild(summary)
1236
            root.appendChild(body)
1237
            return { root, out }
1232
            this.appendRow(row)
1233
            this.mirror(label.textContent + (command === "" ? "" : " " + command))
1234
            return row
1238 1235
          },
1239 1236
1240 1237
          kindLabel(kind) {

@@ -1253,11 +1250,32 @@ defmodule OpenAgentsWeb.ChatLive do

1253 1250
1254 1251
          // N | b64 text | tone(info|warn|error)
1255 1252
          renderNote([, b64text, tone]) {
1256
            const note = document.createElement("div")
1257
            note.className = "deleg-note"
1258
            note.dataset.tone = tone || "info"
1259
            note.textContent = decode64(b64text || "")
1260
            this.el.appendChild(note)
1253
            const text = decode64(b64text || "")
1254
            // An empty note states nothing; spending one of the three visible
1255
            // lines on it would hide real activity.
1256
            if (text === "") return
1257
            const row = this.addRow("deleg-row deleg-row--note")
1258
            row.dataset.tone = tone || "info"
1259
            this.setRow(row, text)
1260
          },
1261
1262
          addRow(className) {
1263
            const row = document.createElement("div")
1264
            row.className = className
1265
            this.el.appendChild(row)
1266
            while (this.el.childElementCount > MAX_ROWS) this.el.firstElementChild.remove()
1267
            return row
1268
          },
1269
1270
          setRow(row, text) {
1271
            row.textContent = text
1272
            this.mirror(text)
1273
          },
1274
1275
          mirror(text) {
1276
            if (!this.activityLine) return
1277
            this.activityLine.textContent = text
1278
            this.activityLine.hidden = text === ""
1261 1279
          }
1262 1280
        }
1263 1281
      </script>

@@ -1497,8 +1515,9 @@ defmodule OpenAgentsWeb.ChatLive do

1497 1515
  # the bounded work list, and the live delegation (issue #85) — and neither is
1498 1516
  # chrome or authority: the transcript's durable event headers remain the
1499 1517
  # record. The rail scrolls on its own so the transcript never moves for it,
1500
  # and the rolling log is owned by the .DelegationLog hook
1501
  # (phx-update="ignore"), so chunk text never rides an assign.
1518
  # and the three-line preview and the activity line under the delegate line
1519
  # are owned by the .DelegationLog hook (phx-update="ignore"), so chunk text
1520
  # never rides an assign.
1502 1521
  defp chat_rail(assigns) do
1503 1522
    ~H"""
1504 1523
    <aside

@@ -1570,6 +1589,18 @@ defmodule OpenAgentsWeb.ChatLive do

1570 1589
                <.icon name="stop" />
1571 1590
              </.button>
1572 1591
            </div>
1592
            <%!-- The activity the delegation is doing right now, stated directly
1593
                  under the delegate line. The log hook mirrors the newest
1594
                  preview row into it; the server only guarantees the mount
1595
                  point and its placement. --%>
1596
            <p
1597
              id={"delegation-activity-rail-#{@delegation.ref}"}
1598
              class="delegation-activity"
1599
              data-delegation-status={@delegation.ref}
1600
              aria-live="polite"
1601
              hidden
1602
            >
1603
            </p>
1573 1604
            <div
1574 1605
              id={"delegation-log-rail-#{@delegation.ref}"}
1575 1606
              class="delegation-log"

@@ -1669,6 +1700,15 @@ defmodule OpenAgentsWeb.ChatLive do

1669 1700
        timestamp={@delegation.started_at}
1670 1701
      >
1671 1702
        <div class="delegation-inline__details">
1703
          <p
1704
            :if={@delegation.state == :running}
1705
            id={"delegation-activity-inline-#{@delegation.ref}"}
1706
            class="delegation-activity"
1707
            data-delegation-status={@delegation.ref}
1708
            aria-live="polite"
1709
            hidden
1710
          >
1711
          </p>
1672 1712
          <div
1673 1713
            :if={@delegation.state == :running}
1674 1714
            id={"delegation-log-inline-#{@delegation.ref}"}
test/openagents_web/live/chat_delegation_rail_test.exs modified +42 -5

@@ -30,7 +30,12 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

30 30
31 31
    # The chunk rides a push event to the log hooks, never an assign; once it
32 32
    # arrives, the start event has necessarily been applied too.
33
    assert_push_event(view, "delegation:chunk", %{text: "hello from the machine"}, 1_000)
33
    assert_push_event(
34
      view,
35
      "delegation:chunk",
36
      %{ref: ref, text: "hello from the machine"},
37
      1_000
38
    )
34 39
35 40
    # Desktop rail: the delegation is a section of the work rail beside the
36 41
    # transcript, never a block under the composer.

@@ -39,9 +44,17 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

39 44
    assert has_element?(view, "#delegation-live .delegation-live__computer", "rail-box")
40 45
    assert has_element?(view, "#delegation-live .delegation-live__subject", "claude")
41 46
    assert has_element?(view, ~s(#cancel-delegation[aria-label="Cancel delegation"]))
42
    assert has_element?(view, "#delegation-live div.delegation-log[phx-update='ignore']")
43 47
    assert has_element?(view, "#delegation-live time[data-started-at]")
44 48
49
    # The activity line sits directly under the delegate line and ships hidden;
50
    # the log hook owns its text client-side, so the server guarantees only the
51
    # mount point, its placement, and that it is scoped to this delegation's
52
    # ref alongside the preview box it mirrors.
53
    assert has_element?(view, "#delegation-activity-rail-#{ref}[hidden]")
54
    assert has_element?(view, ".delegation-live__header + .delegation-activity")
55
    assert has_element?(view, ~s(#delegation-log-rail-#{ref}[data-ref="#{ref}"]))
56
    assert has_element?(view, "#delegation-live div.delegation-log[phx-update='ignore']")
57
45 58
    assert has_element?(
46 59
             view,
47 60
             ~s(#chat-rail-toggle[aria-label="Toggle work panel"][aria-controls="chat-rail-body"])

@@ -57,8 +70,16 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

57 70
    assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
58 71
59 72
    # Narrow-viewport variant: the same projection as an expandable
60
    # event-header section at the transcript tail, live log inside.
73
    # event-header section at the transcript tail, activity line and live
74
    # preview inside.
61 75
    assert has_element?(view, "#delegation-inline #delegation-inline-header.event-header")
76
    assert has_element?(view, "#delegation-activity-inline-#{ref}[hidden]")
77
78
    assert has_element?(
79
             view,
80
             ".delegation-inline__details > .delegation-activity:first-child"
81
           )
82
62 83
    assert has_element?(view, "#delegation-inline div.delegation-log")
63 84
64 85
    FakeController.exit(caller.pid, caller.request_id, %{

@@ -125,12 +146,21 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

125 146
126 147
    first = start_delegation(first_machine, "claude")
127 148
    FakeController.chunk(first.pid, first.request_id, "first delegation working")
128
    assert_push_event(view, "delegation:chunk", %{text: "first delegation working"}, 1_000)
149
150
    assert_push_event(view, "delegation:chunk", %{
151
      ref: first_ref,
152
      text: "first delegation working"
153
    })
154
129 155
    assert has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
130 156
131 157
    second = start_delegation(second_machine, "codex")
132 158
    FakeController.chunk(second.pid, second.request_id, "second delegation working")
133
    assert_push_event(view, "delegation:chunk", %{text: "second delegation working"}, 1_000)
159
160
    assert_push_event(view, "delegation:chunk", %{
161
      ref: second_ref,
162
      text: "second delegation working"
163
    })
134 164
135 165
    # One live panel: the newest delegation owns it; the superseded one is a
136 166
    # bounded summary line beneath.

@@ -138,6 +168,13 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

138 168
    refute has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
139 169
    assert has_element?(view, ".delegation-summary--superseded", "first-box")
140 170
171
    # The activity line and preview are scoped to their delegation's ref, so
172
    # superseding remounts them empty rather than inheriting stale text.
173
    refute has_element?(view, "#delegation-activity-rail-#{first_ref}")
174
    assert has_element?(view, "#delegation-activity-rail-#{second_ref}[hidden]")
175
    refute has_element?(view, "#delegation-log-rail-#{first_ref}")
176
    assert has_element?(view, "#delegation-log-rail-#{second_ref}")
177
141 178
    for caller <- [first, second] do
142 179
      FakeController.exit(caller.pid, caller.request_id, %{
143 180
        "status" => "completed",

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