Revert "Preview a delegation's latest activity in three lines"

465e7a7ce4fc · AtlantisPleb · · parent 3a36f1b053ad

Revert "Preview a delegation's latest activity in three lines"

This reverts commit 3a36f1b053adac92923b862a0ac74fbcfb44e1f8.

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 317 · 2026-08-24T22:45:00.528680Z

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, +136 -177

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

@@ -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. */
3361 3358
  .delegation-log {
3362
    max-height: calc(3 * 1.5em);
3359
    display: flex;
3360
    max-height: 40vh;
3361
    flex-direction: column;
3362
    gap: 6px;
3363 3363
    overflow-y: auto;
3364 3364
    overscroll-behavior: contain;
3365 3365
    color: var(--text-muted);

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

3368 3368
    line-height: 1.5;
3369 3369
  }
3370 3370
3371
  /* ── Preview rows ────────────────────────────────────────────────────────── */
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 ───────────────────────────────────────────────────── */
3372 3380
3373 3381
  /* Built by the .DelegationLog hook, which frames the plain text stream into
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. */
3382
     prose, tool cards and notes. The hook owns the DOM; these are the only
3383
     rules it relies on. */
3377 3384
3378
  .deleg-row {
3379
    min-width: 0;
3380
    max-width: 100%;
3381
    white-space: nowrap;
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;
3382 3390
  }
3383 3391
3384
  .deleg-row--prose,
3385
  .deleg-row--note {
3386
    overflow: hidden;
3387
    text-overflow: ellipsis;
3388
    white-space: nowrap;
3392
  .deleg-tool {
3393
    border: 1px solid var(--line);
3394
    border-radius: var(--radius-sm);
3395
    background: var(--ink-raised);
3389 3396
  }
3390 3397
3391
  .deleg-row--tool {
3398
  .deleg-tool__summary {
3392 3399
    display: flex;
3393 3400
    align-items: center;
3394
    gap: 6px;
3401
    gap: 8px;
3402
    padding: 5px 8px;
3403
    color: var(--text-muted);
3404
    cursor: pointer;
3405
    list-style: none;
3395 3406
  }
3396 3407
3397
  .deleg-row__dot {
3408
  .deleg-tool__summary::-webkit-details-marker {
3409
    display: none;
3410
  }
3411
3412
  .deleg-tool__dot {
3398 3413
    width: 6px;
3399 3414
    height: 6px;
3400 3415
    flex: none;

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

3402 3417
    background: var(--info);
3403 3418
  }
3404 3419
3405
  .deleg-row[data-status="succeeded"] .deleg-row__dot {
3420
  .deleg-tool[data-status="succeeded"] .deleg-tool__dot {
3406 3421
    background: var(--success);
3407 3422
  }
3408 3423
3409
  .deleg-row[data-status="failed"] .deleg-row__dot {
3424
  .deleg-tool[data-status="failed"] .deleg-tool__dot {
3410 3425
    background: var(--danger);
3411 3426
  }
3412 3427
3413
  .deleg-row__label {
3428
  .deleg-tool__label {
3414 3429
    flex: none;
3415 3430
    color: var(--text-body);
3416 3431
  }
3417 3432
3418
  .deleg-row__cmd {
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 {
3419 3437
    min-width: 0;
3420 3438
    flex: 1;
3421 3439
    overflow: hidden;
3422 3440
    color: var(--text-dim);
3423 3441
    text-overflow: ellipsis;
3442
    white-space: nowrap;
3424 3443
  }
3425 3444
3426
  .deleg-row--note[data-tone="warn"] {
3427
    color: var(--warning);
3445
  .deleg-tool__body {
3446
    display: flex;
3447
    flex-direction: column;
3448
    gap: 6px;
3449
    border-top: 1px solid var(--line);
3450
    padding: 8px;
3428 3451
  }
3429 3452
3430
  .deleg-row--note[data-tone="error"] {
3431
    color: var(--danger);
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;
3432 3460
  }
3433 3461
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;
3462
  .deleg-tool__cmd {
3439 3463
    color: var(--text-body);
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;
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);
3445 3481
  }
3446 3482
3447 3483
  .delegation-truncated {
lib/openagents_web/live/chat_live.ex modified +57 -97

@@ -1114,18 +1114,11 @@ 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 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.
1117
        // everything else is agent prose. This hook renders frames as
1118
        // collapsible tool cards and notes, and prose as text — never HTML.
1122 1119
        const RS = String.fromCharCode(30)
1123 1120
        const US = String.fromCharCode(31)
1124 1121
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
1129 1122
        const decode64 = (value) => {
1130 1123
          try {
1131 1124
            return new TextDecoder().decode(Uint8Array.from(atob(value), (c) => c.charCodeAt(0)))

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

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

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

1168 1160
            }
1169 1161
          },
1170 1162
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.
1174 1163
          appendProse(text) {
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)
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)
1183 1169
            }
1170
            this.prose.appendChild(document.createTextNode(text))
1184 1171
          },
1185 1172
1186 1173
          renderFrame(line) {
1187
            this.proseRow = null
1188 1174
            const fields = line.split(US)
1189 1175
            if (fields[0] === "T") {
1190 1176
              this.renderTool(fields)
1191 1177
            } else if (fields[0] === "N") {
1192 1178
              this.renderNote(fields)
1193 1179
            }
1180
            this.prose = null
1194 1181
          },
1195 1182
1196 1183
          // 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.
1200 1184
          renderTool([, id, phase, kind, b64title, b64detail]) {
1201 1185
            const title = decode64(b64title || "")
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)
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)
1207 1192
            }
1208 1193
            if (phase === "1" || phase === "2") {
1209
              row.dataset.status = phase === "1" ? "succeeded" : "failed"
1194
              card.root.dataset.status = phase === "1" ? "succeeded" : "failed"
1195
              if (detail !== "") {
1196
                card.out.textContent = detail
1197
                card.out.hidden = false
1198
              }
1210 1199
            }
1211 1200
          },
1212 1201
1213
          buildToolRow(kind, title, command) {
1214
            const row = document.createElement("div")
1215
            row.className = "deleg-row deleg-row--tool"
1216
            row.dataset.status = "running"
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"
1217 1207
1208
            const summary = document.createElement("summary")
1209
            summary.className = "deleg-tool__summary"
1218 1210
            const dot = document.createElement("span")
1219
            dot.className = "deleg-row__dot"
1211
            dot.className = "deleg-tool__dot"
1220 1212
            const label = document.createElement("span")
1221
            label.className = "deleg-row__label"
1213
            label.className = "deleg-tool__label"
1222 1214
            label.textContent = this.kindLabel(kind) || title || "tool"
1223
            row.appendChild(dot)
1224
            row.appendChild(label)
1215
            summary.appendChild(dot)
1216
            summary.appendChild(label)
1225 1217
            if (command !== "") {
1226
              const cmd = document.createElement("span")
1227
              cmd.className = "deleg-row__cmd"
1228
              cmd.textContent = command
1229
              row.appendChild(cmd)
1218
              const inline = document.createElement("code")
1219
              inline.className = "deleg-tool__inline"
1220
              inline.textContent = command
1221
              summary.appendChild(inline)
1230 1222
            }
1231 1223
1232
            this.appendRow(row)
1233
            this.mirror(label.textContent + (command === "" ? "" : " " + command))
1234
            return row
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 }
1235 1238
          },
1236 1239
1237 1240
          kindLabel(kind) {

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

1250 1253
1251 1254
          // N | b64 text | tone(info|warn|error)
1252 1255
          renderNote([, b64text, tone]) {
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 === ""
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)
1279 1261
          }
1280 1262
        }
1281 1263
      </script>

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

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

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

1589 1570
                <.icon name="stop" />
1590 1571
              </.button>
1591 1572
            </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>
1604 1573
            <div
1605 1574
              id={"delegation-log-rail-#{@delegation.ref}"}
1606 1575
              class="delegation-log"

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

1700 1669
        timestamp={@delegation.started_at}
1701 1670
      >
1702 1671
        <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>
1712 1672
          <div
1713 1673
            :if={@delegation.state == :running}
1714 1674
            id={"delegation-log-inline-#{@delegation.ref}"}
test/openagents_web/live/chat_delegation_rail_test.exs modified +5 -42

@@ -30,12 +30,7 @@ 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(
34
      view,
35
      "delegation:chunk",
36
      %{ref: ref, text: "hello from the machine"},
37
      1_000
38
    )
33
    assert_push_event(view, "delegation:chunk", %{text: "hello from the machine"}, 1_000)
39 34
40 35
    # Desktop rail: the delegation is a section of the work rail beside the
41 36
    # transcript, never a block under the composer.

@@ -44,16 +39,8 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

44 39
    assert has_element?(view, "#delegation-live .delegation-live__computer", "rail-box")
45 40
    assert has_element?(view, "#delegation-live .delegation-live__subject", "claude")
46 41
    assert has_element?(view, ~s(#cancel-delegation[aria-label="Cancel delegation"]))
47
    assert has_element?(view, "#delegation-live time[data-started-at]")
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 42
    assert has_element?(view, "#delegation-live div.delegation-log[phx-update='ignore']")
43
    assert has_element?(view, "#delegation-live time[data-started-at]")
57 44
58 45
    assert has_element?(
59 46
             view,

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

70 57
    assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
71 58
72 59
    # Narrow-viewport variant: the same projection as an expandable
73
    # event-header section at the transcript tail, activity line and live
74
    # preview inside.
60
    # event-header section at the transcript tail, live log inside.
75 61
    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
83 62
    assert has_element?(view, "#delegation-inline div.delegation-log")
84 63
85 64
    FakeController.exit(caller.pid, caller.request_id, %{

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

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

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

168 138
    refute has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
169 139
    assert has_element?(view, ".delegation-summary--superseded", "first-box")
170 140
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
178 141
    for caller <- [first, second] do
179 142
      FakeController.exit(caller.pid, caller.request_id, %{
180 143
        "status" => "completed",

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