Put the chat's work beside the conversation, not under it

273cbd5c5841 · AtlantisPleb · · parent 2092c8f6f637

Put the chat's work beside the conversation, not under it

The chat shell laid its two children out in a column, so the delegation
rail -- a fixed 300px block -- landed under the composer instead of
beside the transcript. That gave the page a transcript squeezed into a
short band, a composer floating mid-page, and the streamed delegation log
as a 40-character ribbon of monospace text.

The shell is a row above 1280px now, and the rail is the second column:
360px, its own scroll region, collapsible to the width of its toggle. It
carries both projections of running work -- the bounded work list and the
live delegation -- so the conversation column is left with one job,
transcript above and composer pinned under it. Below the breakpoint
nothing changes: the rail is gone, work stays in the navigation sidebar,
and the delegation renders inline at the transcript tail.

The log's own rows had no rules at all. `.deleg-prose`, `.deleg-tool` and
`.deleg-note` are built by the .DelegationLog hook and resolved to
nothing, which is the other half of why the stream was unreadable: flex
items at `min-width: auto` made one long command as wide as itself and
scrolled the panel sideways. They wrap now, and a tool step reads as a
card with its status in the dot. `.delegation-summary` was missing its
base rule too, so a finished delegation stacked its dot, its lines and
its dismiss control vertically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <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.

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/live/chat_live.ex
  • modified test/openagents_web/live/chat_delegation_rail_test.exs
  • modified test/openagents_web/live/chat_sidebar_sections_test.exs

Diff

4 files changed, +407 -130

assets/css/openagents.css modified +222 -22

@@ -2668,8 +2668,22 @@

2668 2668
    flex-direction: column;
2669 2669
  }
2670 2670
2671
  /* Wide screens get two columns: the conversation, and the work rail beside
2672
     it. Stacking them was the bug -- the rail is a fixed 360px block, so in a
2673
     column it landed under the composer as a ribbon of monospace text and
2674
     pushed the composer into the middle of the page. */
2675
  @media (min-width: 1280px) {
2676
    .chat-shell {
2677
      flex-direction: row;
2678
    }
2679
  }
2680
2671 2681
  .app-main {
2672 2682
    display: flex;
2683
    /* Both axes: `min-height` for the column it is, `min-width` for the row it
2684
       joins on wide screens, so a long unbroken line in the transcript cannot
2685
       widen the column and squeeze the rail. */
2686
    min-width: 0;
2673 2687
    min-height: 0;
2674 2688
    flex: 1;
2675 2689
    flex-direction: column;

@@ -3087,76 +3101,140 @@

3087 3101
  }
3088 3102
}
3089 3103
3090
/* ── Delegation projection ────────────────────────────────────────────────── */
3104
/* ── Chat work rail ───────────────────────────────────────────────────────── */
3091 3105
3092
/* One projection with two placements: a rail beside the transcript on wide
3093
 * screens, the same content inline at the transcript tail below that. Both are
3094
 * ephemeral -- present only while a delegation is live or recently finished. */
3106
/* The chat surface's right-hand column, on the same idiom as `.docs-toc`: a
3107
 * fixed-width panel held off the content by a hairline, gone once it would
3108
 * crowd what it sits beside. It scrolls on its own, so nothing inside it can
3109
 * move the transcript or the composer.
3110
 *
3111
 * Its two projections each have a second, narrow-viewport placement -- work in
3112
 * the navigation sidebar, the delegation inline at the transcript tail -- and
3113
 * the rules below make sure exactly one of each pair is ever shown. */
3095 3114
3096 3115
@layer components {
3097
  .delegation-rail {
3116
  .chat-rail {
3098 3117
    display: none;
3099 3118
  }
3100 3119
3101 3120
  @media (min-width: 1280px) {
3102
    .delegation-rail {
3121
    .chat-rail {
3103 3122
      display: flex;
3104
      width: 300px;
3123
      /* Wide enough for the streamed log to wrap as prose rather than as a
3124
         ribbon: roughly 60 characters at the log's 12px monospace. */
3125
      width: 360px;
3105 3126
      flex: none;
3106 3127
      flex-direction: column;
3107 3128
      border-left: 1px solid var(--line);
3108 3129
      background: var(--ink-void);
3130
      transition: width var(--motion-fast) var(--ease);
3109 3131
    }
3110 3132
3111
    /* Below this width the same content renders inline instead, so showing
3112
       both would state the delegation twice. */
3133
    /* Collapsed, the rail keeps only the width of its toggle: the reader can
3134
       always get it back, and the conversation takes the space meanwhile. */
3135
    .chat-rail[data-collapsed="true"] {
3136
      width: 48px;
3137
    }
3138
3139
    @media (prefers-reduced-motion: reduce) {
3140
      .chat-rail {
3141
        transition: none;
3142
      }
3143
    }
3144
3145
    /* Below this width each projection has its own placement, so showing these
3146
       too would state the same thing twice. */
3147
    .chat-sidebar-work,
3113 3148
    .delegation-inline {
3114 3149
      display: none;
3115 3150
    }
3116 3151
  }
3117 3152
3118
  .delegation-rail__header {
3153
  .chat-rail__header {
3119 3154
    display: flex;
3120 3155
    flex: none;
3121 3156
    align-items: center;
3122 3157
    justify-content: space-between;
3123 3158
    gap: 8px;
3124 3159
    border-bottom: 1px solid var(--line);
3125
    padding: 8px 12px 8px 16px;
3160
    padding: 8px 8px 8px 16px;
3126 3161
  }
3127 3162
3128
  .delegation-rail__label {
3163
  .chat-rail__label {
3164
    overflow: hidden;
3129 3165
    color: var(--text-muted);
3130 3166
    font-size: 0.6875rem;
3131 3167
    font-weight: 600;
3132 3168
    letter-spacing: 0.06em;
3133 3169
    text-transform: uppercase;
3170
    white-space: nowrap;
3134 3171
  }
3135 3172
3136
  .delegation-rail__body {
3173
  .chat-rail[data-collapsed="true"] .chat-rail__label {
3174
    display: none;
3175
  }
3176
3177
  .chat-rail[data-collapsed="true"] .chat-rail__header {
3178
    justify-content: center;
3179
    padding-inline: 8px;
3180
  }
3181
3182
  .chat-rail__body {
3137 3183
    display: flex;
3138 3184
    min-height: 0;
3139 3185
    flex: 1;
3140 3186
    flex-direction: column;
3141
    gap: 12px;
3187
    gap: 20px;
3142 3188
    overflow-y: auto;
3143
    padding: 12px 16px;
3189
    overscroll-behavior: contain;
3190
    padding: 12px 0;
3144 3191
  }
3145 3192
3146
  .delegation-rail[data-collapsed="true"] .delegation-rail__body {
3193
  .chat-rail[data-collapsed="true"] .chat-rail__body {
3147 3194
    display: none;
3148 3195
  }
3149 3196
3150
  .delegation-rail__glyph-collapse,
3151
  .delegation-rail[data-collapsed="true"] .delegation-rail__glyph-expand {
3197
  .chat-rail__glyph-collapse,
3198
  .chat-rail[data-collapsed="true"] .chat-rail__glyph-expand {
3152 3199
    display: inline-flex;
3153 3200
  }
3154 3201
3155
  .delegation-rail__glyph-expand,
3156
  .delegation-rail[data-collapsed="true"] .delegation-rail__glyph-collapse {
3202
  .chat-rail__glyph-expand,
3203
  .chat-rail[data-collapsed="true"] .chat-rail__glyph-collapse {
3157 3204
    display: none;
3158 3205
  }
3159 3206
3207
  .rail-section {
3208
    display: flex;
3209
    min-width: 0;
3210
    flex-direction: column;
3211
    gap: 8px;
3212
  }
3213
3214
  /* 20px is where a sidebar row's text starts (12px margin plus 8px padding),
3215
     so the heading, the work rows and the delegation below them all sit on one
3216
     left edge while the rows keep the wider hit area their wash draws. */
3217
  .rail-section__label {
3218
    margin-inline: 20px;
3219
    color: var(--text-dim);
3220
    font-size: 0.6875rem;
3221
    font-weight: 600;
3222
    letter-spacing: 0.06em;
3223
    text-transform: uppercase;
3224
  }
3225
3226
  .delegation-rail > :not(.rail-section__label) {
3227
    margin-inline: 20px;
3228
  }
3229
}
3230
3231
/* ── Delegation projection ────────────────────────────────────────────────── */
3232
3233
/* One projection with two placements: the rail beside the transcript on wide
3234
 * screens, the same content inline at the transcript tail below that. Both are
3235
 * ephemeral -- present only while a delegation is live or recently finished. */
3236
3237
@layer components {
3160 3238
  .delegation-inline {
3161 3239
    width: 100%;
3162 3240
    max-width: 46rem;

@@ -3213,12 +3291,125 @@

3213 3291
    display: flex;
3214 3292
    max-height: 40vh;
3215 3293
    flex-direction: column;
3216
    gap: 2px;
3294
    gap: 6px;
3217 3295
    overflow-y: auto;
3296
    overscroll-behavior: contain;
3218 3297
    color: var(--text-muted);
3219 3298
    font-family: var(--font-mono, ui-monospace, monospace);
3220 3299
    font-size: 0.75rem;
3221
    line-height: 1.4;
3300
    line-height: 1.5;
3301
  }
3302
3303
  /* The log only ever scrolls vertically. Its rows are flex items, which are
3304
     `min-width: auto` by default, so one long command or path made the whole
3305
     column as wide as itself and the panel scrolled sideways instead. */
3306
  .delegation-log > * {
3307
    min-width: 0;
3308
    max-width: 100%;
3309
  }
3310
3311
  /* ── Streamed log rows ───────────────────────────────────────────────────── */
3312
3313
  /* Built by the .DelegationLog hook, which frames the plain text stream into
3314
     prose, tool cards and notes. The hook owns the DOM; these are the only
3315
     rules it relies on. */
3316
3317
  .deleg-prose {
3318
    overflow-wrap: anywhere;
3319
    /* The stream carries its own line breaks, and they are the shape of what
3320
       the agent said; the rest wraps to the rail's width. */
3321
    white-space: pre-wrap;
3322
  }
3323
3324
  .deleg-tool {
3325
    border: 1px solid var(--line);
3326
    border-radius: var(--radius-sm);
3327
    background: var(--ink-raised);
3328
  }
3329
3330
  .deleg-tool__summary {
3331
    display: flex;
3332
    align-items: center;
3333
    gap: 8px;
3334
    padding: 5px 8px;
3335
    color: var(--text-muted);
3336
    cursor: pointer;
3337
    list-style: none;
3338
  }
3339
3340
  .deleg-tool__summary::-webkit-details-marker {
3341
    display: none;
3342
  }
3343
3344
  .deleg-tool__dot {
3345
    width: 6px;
3346
    height: 6px;
3347
    flex: none;
3348
    border-radius: 9999px;
3349
    background: var(--info);
3350
  }
3351
3352
  .deleg-tool[data-status="succeeded"] .deleg-tool__dot {
3353
    background: var(--success);
3354
  }
3355
3356
  .deleg-tool[data-status="failed"] .deleg-tool__dot {
3357
    background: var(--danger);
3358
  }
3359
3360
  .deleg-tool__label {
3361
    flex: none;
3362
    color: var(--text-body);
3363
  }
3364
3365
  /* The command as a one-line preview beside its label. It is the detail that
3366
     tells two `Terminal` steps apart, so it is stated on the closed card and
3367
     truncated rather than wrapped. */
3368
  .deleg-tool__inline {
3369
    min-width: 0;
3370
    flex: 1;
3371
    overflow: hidden;
3372
    color: var(--text-dim);
3373
    text-overflow: ellipsis;
3374
    white-space: nowrap;
3375
  }
3376
3377
  .deleg-tool__body {
3378
    display: flex;
3379
    flex-direction: column;
3380
    gap: 6px;
3381
    border-top: 1px solid var(--line);
3382
    padding: 8px;
3383
  }
3384
3385
  .deleg-tool__cmd,
3386
  .deleg-tool__out {
3387
    max-height: 16rem;
3388
    overflow: auto;
3389
    overflow-wrap: anywhere;
3390
    font: inherit;
3391
    white-space: pre-wrap;
3392
  }
3393
3394
  .deleg-tool__cmd {
3395
    color: var(--text-body);
3396
  }
3397
3398
  .deleg-tool__out {
3399
    color: var(--text-muted);
3400
  }
3401
3402
  .deleg-note {
3403
    overflow-wrap: anywhere;
3404
    color: var(--text-dim);
3405
  }
3406
3407
  .deleg-note[data-tone="warn"] {
3408
    color: var(--warning);
3409
  }
3410
3411
  .deleg-note[data-tone="error"] {
3412
    color: var(--danger);
3222 3413
  }
3223 3414
3224 3415
  .delegation-truncated {

@@ -3226,6 +3417,15 @@

3226 3417
    font-size: 0.6875rem;
3227 3418
  }
3228 3419
3420
  /* Dot, two lines, and an optional trailing control on one row -- the same
3421
     shape as the work rows above it, so the rail reads as one list. */
3422
  .delegation-summary {
3423
    display: flex;
3424
    min-width: 0;
3425
    align-items: center;
3426
    gap: 8px;
3427
  }
3428
3229 3429
  .delegation-summary__lines {
3230 3430
    display: flex;
3231 3431
    min-width: 0;
lib/openagents_web/live/chat_live.ex modified +141 -98

@@ -70,7 +70,7 @@ defmodule OpenAgentsWeb.ChatLive do

70 70
      |> assign(:paced_voice_items, MapSet.new())
71 71
      |> assign(:delegation, nil)
72 72
      |> assign(:delegation_summaries, [])
73
      |> assign(:delegation_collapsed, false)
73
      |> assign(:rail_collapsed, false)
74 74
      |> stream(:messages, messages)
75 75
76 76
    {:ok, socket}

@@ -144,9 +144,10 @@ defmodule OpenAgentsWeb.ChatLive do

144 144
  # Collapse state is a server assign, not a client attribute toggle: the rail
145 145
  # re-renders on every streamed chunk, so a DOM-only `data-collapsed` snapped
146 146
  # back open on the next patch. Holding it here keeps the rail collapsed until
147
  # the reader expands it again.
148
  def handle_event("toggle_delegation_rail", _params, socket) do
149
    {:noreply, assign(socket, :delegation_collapsed, !socket.assigns.delegation_collapsed)}
147
  # the reader expands it again. It is deliberately not persisted: the rail is
148
  # a view of what is happening now, so every visit starts open.
149
  def handle_event("toggle_rail", _params, socket) do
150
    {:noreply, assign(socket, :rail_collapsed, !socket.assigns.rail_collapsed)}
150 151
  end
151 152
152 153
  @impl true

@@ -811,14 +812,18 @@ defmodule OpenAgentsWeb.ChatLive do

811 812
          </footer>
812 813
        </main>
813 814
814
        <%!-- Desktop only (≥1280px): the slim live delegation rail, the shell's
815
              third grid column. Ephemeral chrome — rendered only while a
816
              delegation is in flight or recently finished, never permanent. --%>
817
        <.delegation_rail
818
          :if={@delegation != nil or @delegation_summaries != []}
815
        <%!-- Desktop only (≥1280px): the work rail, the shell's second column.
816
              The chat column keeps one job — transcript above, composer pinned
817
              under it — and everything about running work moves out here, where
818
              a streamed log has the width to be read. Below the breakpoint the
819
              rail is gone and the same two projections render in the navigation
820
              sidebar and at the transcript tail. --%>
821
        <.chat_rail
822
          :if={@recent_jobs != [] or @delegation != nil or @delegation_summaries != []}
823
          recent_jobs={@recent_jobs}
819 824
          delegation={@delegation}
820 825
          summaries={@delegation_summaries}
821
          collapsed={@delegation_collapsed}
826
          collapsed={@rail_collapsed}
822 827
        />
823 828
      </div>
824 829

@@ -1173,17 +1178,17 @@ defmodule OpenAgentsWeb.ChatLive do

1173 1178
            a stretched anchor to it; a row with no target is stated, not
1174 1179
            linked. Empty sections keep their labels and say so honestly. --%>
1175 1180
    <div id="sidebar-sections" class="sidebar-sections">
1176
      <section :if={@recent_jobs != []} id="sidebar-work" class="sidebar-section" aria-label="Work">
1181
      <%!-- The narrow-viewport placement of the work projection. Above 1280px
1182
      the work rail beside the transcript states it instead, so the stylesheet
1183
      hides this one; one projection, two placements, exactly one shown. --%>
1184
      <section
1185
        :if={@recent_jobs != []}
1186
        id="sidebar-work"
1187
        class="sidebar-section chat-sidebar-work"
1188
        aria-label="Work"
1189
      >
1177 1190
        <h2 class="sidebar-section-label scroll-edge-hairline">WORK</h2>
1178
        <.sidebar_status_row
1179
          :for={job <- @recent_jobs}
1180
          id={"sidebar-job-#{job.id}"}
1181
          target_message_id={job.report_message_id}
1182
          dot_state={job_dot_state(job)}
1183
          title={job_title(job)}
1184
          meta={job_meta(job)}
1185
          data-status={job.status}
1186
        />
1191
        <.work_rows id_prefix="sidebar-job" recent_jobs={@recent_jobs} />
1187 1192
      </section>
1188 1193
    </div>
1189 1194

@@ -1216,6 +1221,26 @@ defmodule OpenAgentsWeb.ChatLive do

1216 1221
    """
1217 1222
  end
1218 1223
1224
  attr :id_prefix, :string, required: true
1225
  attr :recent_jobs, :list, required: true
1226
1227
  # The work projection's rows, shared by its two placements. Only the DOM id
1228
  # prefix differs, because both placements are in the document at once and the
1229
  # stylesheet — not the server — decides which one the viewport shows.
1230
  defp work_rows(assigns) do
1231
    ~H"""
1232
    <.sidebar_status_row
1233
      :for={job <- @recent_jobs}
1234
      id={"#{@id_prefix}-#{job.id}"}
1235
      target_message_id={job.report_message_id}
1236
      dot_state={job_dot_state(job)}
1237
      title={job_title(job)}
1238
      meta={job_meta(job)}
1239
      data-status={job.status}
1240
    />
1241
    """
1242
  end
1243
1219 1244
  attr :id, :string, required: true
1220 1245
  attr :target_message_id, :any, default: nil
1221 1246
  attr :dot_state, :string, required: true

@@ -1320,108 +1345,126 @@ defmodule OpenAgentsWeb.ChatLive do

1320 1345
1321 1346
  defp duration_label(seconds), do: "#{div(seconds, 3600)}h #{seconds |> rem(3600) |> div(60)}m"
1322 1347
1348
  attr :recent_jobs, :list, required: true
1323 1349
  attr :delegation, :map, default: nil
1324 1350
  attr :summaries, :list, required: true
1325 1351
  attr :collapsed, :boolean, default: false
1326 1352
1327
  # The live delegation rail (issue #85): a slim ephemeral right rail shown
1328
  # only while a computer delegation is in flight or recently finished. It is
1329
  # a projection of the PubSub stream, never chrome and never authority — the
1330
  # transcript's durable event header remains the record. One live panel at a
1331
  # time; superseded and finished delegations collapse to summary lines. The
1332
  # rolling log is owned by the .DelegationLog hook (phx-update="ignore"), so
1333
  # chunk text never rides an assign.
1334
  defp delegation_rail(assigns) do
1353
  # The work rail: the chat surface's right-hand column on wide screens, and
1354
  # the only place running work is stated there. It carries two projections —
1355
  # the bounded work list, and the live delegation (issue #85) — and neither is
1356
  # chrome or authority: the transcript's durable event headers remain the
1357
  # record. The rail scrolls on its own so the transcript never moves for it,
1358
  # and the rolling log is owned by the .DelegationLog hook
1359
  # (phx-update="ignore"), so chunk text never rides an assign.
1360
  defp chat_rail(assigns) do
1335 1361
    ~H"""
1336 1362
    <aside
1337
      id="delegation-rail"
1338
      class="delegation-rail"
1363
      id="chat-rail"
1364
      class="chat-rail"
1339 1365
      data-collapsed={to_string(@collapsed)}
1340
      aria-label="Live delegation"
1366
      aria-label="Work"
1341 1367
    >
1342
      <header class="delegation-rail__header">
1343
        <h2 class="delegation-rail__label">LIVE DELEGATION</h2>
1368
      <header class="chat-rail__header">
1369
        <h2 class="chat-rail__label">WORK</h2>
1344 1370
        <.button
1345
          id="delegation-rail-toggle"
1371
          id="chat-rail-toggle"
1346 1372
          variant={:ghost}
1347 1373
          size={:sm}
1348
          class="delegation-rail__toggle"
1349
          aria-label="Toggle delegation panel"
1374
          class="chat-rail__toggle"
1375
          aria-label="Toggle work panel"
1350 1376
          aria-expanded={to_string(!@collapsed)}
1351
          aria-controls="delegation-rail"
1352
          phx-click="toggle_delegation_rail"
1377
          aria-controls="chat-rail-body"
1378
          phx-click="toggle_rail"
1353 1379
        >
1354
          <.icon name="sidebar-collapse-right" class="delegation-rail__glyph-collapse" />
1355
          <.icon name="sidebar-open-right" class="delegation-rail__glyph-expand" />
1380
          <.icon name="sidebar-collapse-right" class="chat-rail__glyph-collapse" />
1381
          <.icon name="sidebar-open-right" class="chat-rail__glyph-expand" />
1356 1382
        </.button>
1357 1383
      </header>
1358 1384
1359
      <div class="delegation-rail__body">
1360
        <div
1361
          :if={@delegation && @delegation.state == :running}
1362
          id="delegation-live"
1363
          class="delegation-live"
1364
          data-status="running"
1385
      <div id="chat-rail-body" class="chat-rail__body">
1386
        <%!-- The wide-viewport placement of the work projection; the navigation
1387
              sidebar states it below the breakpoint. --%>
1388
        <%!-- No accessible name of its own: the rail's own heading already
1389
              names it, and a second "Work" landmark inside a "Work" one only
1390
              adds a level for a screen reader to walk through. --%>
1391
        <section :if={@recent_jobs != []} id="rail-work" class="rail-section">
1392
          <.work_rows id_prefix="rail-job" recent_jobs={@recent_jobs} />
1393
        </section>
1394
1395
        <section
1396
          :if={@delegation != nil or @summaries != []}
1397
          id="delegation-rail"
1398
          class="rail-section delegation-rail"
1399
          aria-label="Live delegation"
1365 1400
        >
1366
          <div class="delegation-live__header">
1367
            <.status_indicator state="running" label="RUNNING" />
1368
            <span class="delegation-live__machine">{@delegation.machine_name}</span>
1369
            <span class="delegation-live__subject">{delegation_subject(@delegation)}</span>
1370
            <time
1371
              id={"delegation-elapsed-#{@delegation.ref}"}
1372
              class="delegation-live__elapsed"
1373
              datetime={DateTime.to_iso8601(@delegation.started_at)}
1374
              data-started-at={DateTime.to_iso8601(@delegation.started_at)}
1375
              phx-hook=".DelegationClock"
1401
          <h3 class="rail-section__label">LIVE DELEGATION</h3>
1402
          <div
1403
            :if={@delegation && @delegation.state == :running}
1404
            id="delegation-live"
1405
            class="delegation-live"
1406
            data-status="running"
1407
          >
1408
            <div class="delegation-live__header">
1409
              <.status_indicator state="running" label="RUNNING" />
1410
              <span class="delegation-live__machine">{@delegation.machine_name}</span>
1411
              <span class="delegation-live__subject">{delegation_subject(@delegation)}</span>
1412
              <time
1413
                id={"delegation-elapsed-#{@delegation.ref}"}
1414
                class="delegation-live__elapsed"
1415
                datetime={DateTime.to_iso8601(@delegation.started_at)}
1416
                data-started-at={DateTime.to_iso8601(@delegation.started_at)}
1417
                phx-hook=".DelegationClock"
1418
                phx-update="ignore"
1419
              ></time>
1420
              <.button
1421
                id="cancel-delegation"
1422
                variant={:ghost}
1423
                size={:xs}
1424
                class="delegation-live__cancel"
1425
                aria-label="Cancel delegation"
1426
                phx-click="cancel_delegation"
1427
              >
1428
                <.icon name="stop" />
1429
              </.button>
1430
            </div>
1431
            <div
1432
              id={"delegation-log-rail-#{@delegation.ref}"}
1433
              class="delegation-log"
1434
              data-ref={@delegation.ref}
1435
              phx-hook=".DelegationLog"
1376 1436
              phx-update="ignore"
1377
            ></time>
1437
            >
1438
            </div>
1439
            <.badge :if={@delegation.truncated?} variant={:dim} class="delegation-truncated">
1440
              TRUNCATED
1441
            </.badge>
1442
          </div>
1443
1444
          <.delegation_summary_row
1445
            :if={@delegation && @delegation.state == :terminal}
1446
            id="delegation-terminal"
1447
            delegation={@delegation}
1448
          >
1378 1449
            <.button
1379
              id="cancel-delegation"
1450
              id="delegation-dismiss"
1380 1451
              variant={:ghost}
1381 1452
              size={:xs}
1382
              class="delegation-live__cancel"
1383
              aria-label="Cancel delegation"
1384
              phx-click="cancel_delegation"
1453
              class="delegation-summary__dismiss"
1454
              aria-label="Dismiss"
1455
              phx-click="dismiss_delegation"
1385 1456
            >
1386
              <.icon name="stop" />
1457
              <.icon name="x" />
1387 1458
            </.button>
1388
          </div>
1389
          <div
1390
            id={"delegation-log-rail-#{@delegation.ref}"}
1391
            class="delegation-log"
1392
            data-ref={@delegation.ref}
1393
            phx-hook=".DelegationLog"
1394
            phx-update="ignore"
1395
          >
1396
          </div>
1397
          <.badge :if={@delegation.truncated?} variant={:dim} class="delegation-truncated">
1398
            TRUNCATED
1399
          </.badge>
1400
        </div>
1401
1402
        <.delegation_summary_row
1403
          :if={@delegation && @delegation.state == :terminal}
1404
          id="delegation-terminal"
1405
          delegation={@delegation}
1406
        >
1407
          <.button
1408
            id="delegation-dismiss"
1409
            variant={:ghost}
1410
            size={:xs}
1411
            class="delegation-summary__dismiss"
1412
            aria-label="Dismiss"
1413
            phx-click="dismiss_delegation"
1414
          >
1415
            <.icon name="x" />
1416
          </.button>
1417
        </.delegation_summary_row>
1459
          </.delegation_summary_row>
1418 1460
1419
        <.delegation_summary_row
1420
          :for={summary <- @summaries}
1421
          id={"delegation-summary-#{summary.ref}"}
1422
          delegation={summary}
1423
          class="delegation-summary--superseded"
1424
        />
1461
          <.delegation_summary_row
1462
            :for={summary <- @summaries}
1463
            id={"delegation-summary-#{summary.ref}"}
1464
            delegation={summary}
1465
            class="delegation-summary--superseded"
1466
          />
1467
        </section>
1425 1468
      </div>
1426 1469
    </aside>
1427 1470
    """
test/openagents_web/live/chat_delegation_rail_test.exs modified +15 -10

@@ -9,10 +9,11 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

9 9
  # 65,536-byte cumulative cap.
10 10
  @maximum_event_bytes 16_384
11 11
12
  test "no delegation means no rail and no inline panel", %{conn: conn} do
12
  test "no work and no delegation means no rail at all", %{conn: conn} do
13 13
    conn = log_in_github_user(conn, "delegation-idle-browser")
14 14
    {:ok, view, _html} = live(conn, ~p"/chat")
15 15
16
    refute has_element?(view, "#chat-rail")
16 17
    refute has_element?(view, "#delegation-rail")
17 18
    refute has_element?(view, "#delegation-inline")
18 19
  end

@@ -31,7 +32,9 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

31 32
    # arrives, the start event has necessarily been applied too.
32 33
    assert_push_event(view, "delegation:chunk", %{text: "hello from the machine"}, 1_000)
33 34
34
    # Desktop rail: header facts, running state, the hook-owned log and clock.
35
    # Desktop rail: the delegation is a section of the work rail beside the
36
    # transcript, never a block under the composer.
37
    assert has_element?(view, "#chat-rail #chat-rail-body #delegation-rail")
35 38
    assert has_element?(view, "#delegation-rail #delegation-live[data-status='running']")
36 39
    assert has_element?(view, "#delegation-live .delegation-live__machine", "rail-box")
37 40
    assert has_element?(view, "#delegation-live .delegation-live__subject", "claude")

@@ -41,17 +44,17 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

41 44
42 45
    assert has_element?(
43 46
             view,
44
             ~s(#delegation-rail-toggle[aria-label="Toggle delegation panel"])
47
             ~s(#chat-rail-toggle[aria-label="Toggle work panel"][aria-controls="chat-rail-body"])
45 48
           )
46 49
47 50
    # Collapse is a server assign so it survives the rail re-rendering on every
48 51
    # streamed chunk: the toggle flips it and it stays flipped.
49
    assert has_element?(view, ~s(#delegation-rail[data-collapsed="false"]))
50
    view |> element("#delegation-rail-toggle") |> render_click()
51
    assert has_element?(view, ~s(#delegation-rail[data-collapsed="true"]))
52
    assert has_element?(view, ~s(#delegation-rail-toggle[aria-expanded="false"]))
53
    view |> element("#delegation-rail-toggle") |> render_click()
54
    assert has_element?(view, ~s(#delegation-rail[data-collapsed="false"]))
52
    assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
53
    view |> element("#chat-rail-toggle") |> render_click()
54
    assert has_element?(view, ~s(#chat-rail[data-collapsed="true"]))
55
    assert has_element?(view, ~s(#chat-rail-toggle[aria-expanded="false"]))
56
    view |> element("#chat-rail-toggle") |> render_click()
57
    assert has_element?(view, ~s(#chat-rail[data-collapsed="false"]))
55 58
56 59
    # Narrow-viewport variant: the same projection as an expandable
57 60
    # event-header section at the transcript tail, live log inside.

@@ -78,10 +81,12 @@ defmodule OpenAgentsWeb.ChatDelegationRailTest do

78 81
    assert has_element?(view, ~s(#delegation-dismiss[aria-label="Dismiss"]))
79 82
80 83
    # Dismissing clears the whole ephemeral projection; the durable event
81
    # header in the transcript remains the record.
84
    # header in the transcript remains the record. With no work either, the
85
    # rail has nothing left to state and goes with it.
82 86
    view |> element("#delegation-dismiss") |> render_click()
83 87
    refute has_element?(view, "#delegation-rail")
84 88
    refute has_element?(view, "#delegation-inline")
89
    refute has_element?(view, "#chat-rail")
85 90
  end
86 91
87 92
  test "the capped stream renders an explicit truncation marker", %{conn: conn} do
test/openagents_web/live/chat_sidebar_sections_test.exs modified +29

@@ -49,6 +49,35 @@ defmodule OpenAgentsWeb.ChatSidebarSectionsTest do

49 49
           )
50 50
  end
51 51
52
  test "work has two placements: the navigation sidebar and the wide-screen rail",
53
       %{conn: conn} do
54
    user = github_user("sidebar-sections-rail-browser")
55
    conn = log_in_github_user(conn, "sidebar-sections-rail-browser")
56
57
    {:ok, conversation} = Conversations.ensure_conversation(user)
58
    owner = Conversations.get_conversation_owner!(conversation)
59
60
    {:ok, job} =
61
      Work.create_job(%{
62
        conversation_id: conversation.id,
63
        owner_visitor_id: owner.id,
64
        surface: "text",
65
        goal: "Rebuild the staging index"
66
      })
67
68
    {:ok, view, _html} = live(conn, ~p"/chat")
69
70
    # Both placements are in the document and the stylesheet shows exactly one:
71
    # the sidebar section below 1280px, the rail above it.
72
    assert has_element?(view, "#sidebar-work.chat-sidebar-work #sidebar-job-#{job.id}")
73
    assert has_element?(view, "#chat-rail #chat-rail-body #rail-work #rail-job-#{job.id}")
74
75
    # The rail is the conversation column's sibling, not something stacked
76
    # under the composer, so the transcript keeps the height it is given.
77
    assert has_element?(view, ".chat-shell > .app-main")
78
    assert has_element?(view, ".chat-shell > #chat-rail")
79
  end
80
52 81
  test "job lifecycle broadcasts refresh the work section without polling", %{conn: conn} do
53 82
    user = github_user("sidebar-sections-job-broadcast-browser")
54 83
    conn = log_in_github_user(conn, "sidebar-sections-job-broadcast-browser")

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