fix(chat): remove the gated /chat work surfaces instead of toggling them off

47f7a3478ffe · AtlantisPleb · · parent 533531af24f0

fix(chat): remove the gated /chat work surfaces instead of toggling them off

Commit 195a03e zero-based the /chat interface by rewriting four render
guards to `:if={false and <condition>}`. The surfaces stopped rendering,
but the components, assigns, helpers, and comments behind them stayed,
and the comments went on describing a rail, a sidebar work section, an
inline delegation panel, and a deep-work rollup header as if they were
live. Seven tests kept asserting the old behavior and failed on main.

Remove the four blocks and everything that became unreachable with them:
`chat_rail/1`, `delegation_inline/1`, `delegation_summary_row/1`,
`work_rows/1`, `sidebar_status_row/1`, the job and delegation display
helpers, the `.DelegationLog` and `.DelegationClock` colocated hooks, the
`cancel_delegation`, `dismiss_delegation`, and `toggle_rail` events, the
four `computer_live_*` and two `work_job_updated` handlers, the assigns
that fed them, the two now-unused aliases, and the CSS for all of it.
`Conversations.list_work_job_rollups_by_message/1` loses its only caller
and goes with them.

A named toggle was the alternative. It was rejected: no test can cover
the branch that never runs, so the code would rot behind a name that
claims it still works. Git history is the restore path.

The dropped state was projection, not authority. `OpenAgents.Work` job
lifecycle broadcasts and `OpenAgents.ComputerActivity`'s bounded stream —
including its owner-scoping, the security-shaped property the deleted
LiveView test also covered — keep their own proofs in
`OpenAgents.WorkJobTest` and `OpenAgents.ComputerActivityTest`.
`ComputerActivity` stays and now says plainly that no surface subscribes.

Tests follow the decision rather than the old surface.
`ChatWorkSurfacesAbsentTest` replaces `ChatSidebarSectionsTest` and
`ChatDelegationRailTest`: it holds that a job that ran, a streamed
delegation, and a deep-work report each render no rail, section, panel,
or rollup, and that another account's conversation never receives the
stream. The agent-jobs controller test and the chat sidebar test drop
their assertions on the removed markup and assert its absence instead.

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

Deploy story

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

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 INVARIANTS.md
  • modified assets/css/app.css
  • modified assets/css/openagents.css
  • modified lib/openagents/computer/acp_transcript.ex
  • modified lib/openagents/computer_activity.ex
  • modified lib/openagents/conversations.ex
  • modified lib/openagents/work.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified test/openagents_web/controllers/computer_agent_jobs_controller_test.exs
  • deleted test/openagents_web/live/chat_delegation_rail_test.exs
  • modified test/openagents_web/live/chat_live_test.exs
  • deleted test/openagents_web/live/chat_sidebar_sections_test.exs
  • added test/openagents_web/live/chat_work_surfaces_absent_test.exs

Diff

13 files changed, +234 -1694

INVARIANTS.md modified +2 -1

@@ -3376,7 +3376,8 @@ Evidence: `OpenAgents.Conversations.list_tool_step_activity/1`,

3376 3376
`OpenAgents.ComputerActivity`, `OpenAgentsWeb.ChatLive`, tool activity tests in
3377 3377
`OpenAgentsWeb.ChatLiveTest`, `OpenAgentsWeb.ToolActivityTest`,
3378 3378
`OpenAgents.ComputerActivityTest` (whose exact live-event key sets pin the
3379
ephemeral projection), `OpenAgentsWeb.ChatDelegationRailTest`, and the
3379
ephemeral projection), `OpenAgentsWeb.ChatWorkSurfacesAbsentTest` (which pins
3380
that `/chat` renders none of it), and the
3380 3381
projection enumeration in `OpenAgentsWeb.ToolActivityProjectionTest`.
3381 3382
3382 3383
### UI-003 — Product surfaces render only through the sanctioned component library
assets/css/app.css modified -7

@@ -531,13 +531,6 @@ body.sidebar-open {

531 531
  text-overflow: ellipsis;
532 532
}
533 533
534
.sidebar-sections {
535
  position: relative;
536
  min-height: 0;
537
  flex: 1;
538
  overflow-y: auto;
539
}
540
541 534
.sidebar-section {
542 535
  position: relative;
543 536
  /* A section heading needs air above it or it reads as one more row in the
assets/css/openagents.css modified +3 -414

@@ -2740,28 +2740,17 @@

2740 2740
    flex-direction: column;
2741 2741
  }
2742 2742
2743
  /* Wide screens get two columns: the conversation, and the work rail beside
2744
     it. Stacking them was the bug -- the rail is a fixed 360px block, so in a
2745
     column it landed under the composer as a ribbon of monospace text and
2746
     pushed the composer into the middle of the page. */
2747
  @media (min-width: 1280px) {
2748
    .chat-shell {
2749
      flex-direction: row;
2750
    }
2751
  }
2752
2753 2743
  .app-main {
2754 2744
    display: flex;
2755
    /* Both axes: `min-height` for the column it is, `min-width` for the row it
2756
       joins on wide screens, so a long unbroken line in the transcript cannot
2757
       widen the column and squeeze the rail. */
2745
    /* Both axes: `min-height` for the column it is, and `min-width` so a long
2746
       unbroken line in the transcript cannot widen the column past its
2747
       parent. */
2758 2748
    min-width: 0;
2759 2749
    min-height: 0;
2760 2750
    flex: 1;
2761 2751
    flex-direction: column;
2762 2752
  }
2763 2753
2764
2765 2754
  /* ── Transcript ─────────────────────────────────────────────────────────── */
2766 2755
2767 2756
  /* Not a scroller any more: `conversation/1` is, and it clips at its own

@@ -3003,46 +2992,6 @@

3003 2992
    max-width: 46rem;
3004 2993
    margin-inline: auto;
3005 2994
  }
3006
3007
  .job-rollup {
3008
    display: flex;
3009
    align-items: center;
3010
    gap: 8px;
3011
    color: var(--text-dim);
3012
    font-size: 0.75rem;
3013
  }
3014
3015
  /* ── Two-line sidebar rows (the work projection) ─────────────────────────── */
3016
3017
  .sidebar-row__lines {
3018
    display: flex;
3019
    min-width: 0;
3020
    flex-direction: column;
3021
  }
3022
3023
  .sidebar-row__title {
3024
    overflow: hidden;
3025
    color: var(--text-muted);
3026
    font-size: 0.8125rem;
3027
    line-height: 1.25rem;
3028
    text-overflow: ellipsis;
3029
    white-space: nowrap;
3030
  }
3031
3032
  .sidebar-row__meta {
3033
    color: var(--text-dim);
3034
    font-size: 0.75rem;
3035
    line-height: 1rem;
3036
  }
3037
3038
  .sidebar-row__dot {
3039
    flex: none;
3040
  }
3041
3042
  /* A two-line row cannot use the single-line row's height. */
3043
  .sidebar-row:has(.sidebar-row__lines) {
3044
    min-height: 44px;
3045
  }
3046 2995
}
3047 2996
3048 2997
/* Screen-reader-only text. Not `display: none`, which removes it from the

@@ -3173,366 +3122,6 @@

3173 3122
  }
3174 3123
}
3175 3124
3176
/* ── Chat work rail ───────────────────────────────────────────────────────── */
3177
3178
/* The chat surface's right-hand column, on the same idiom as `.docs-toc`: a
3179
 * fixed-width panel held off the content by a hairline, gone once it would
3180
 * crowd what it sits beside. It scrolls on its own, so nothing inside it can
3181
 * move the transcript or the composer.
3182
 *
3183
 * Its two projections each have a second, narrow-viewport placement -- work in
3184
 * the navigation sidebar, the delegation inline at the transcript tail -- and
3185
 * the rules below make sure exactly one of each pair is ever shown. */
3186
3187
@layer components {
3188
  .chat-rail {
3189
    display: none;
3190
  }
3191
3192
  @media (min-width: 1280px) {
3193
    .chat-rail {
3194
      display: flex;
3195
      /* Wide enough for the streamed log to wrap as prose rather than as a
3196
         ribbon: roughly 60 characters at the log's 12px monospace. */
3197
      width: 360px;
3198
      flex: none;
3199
      flex-direction: column;
3200
      border-left: 1px solid var(--line);
3201
      background: var(--ink-void);
3202
      transition: width var(--motion-fast) var(--ease);
3203
    }
3204
3205
    /* Collapsed, the rail keeps only the width of its toggle: the reader can
3206
       always get it back, and the conversation takes the space meanwhile. */
3207
    .chat-rail[data-collapsed="true"] {
3208
      width: 48px;
3209
    }
3210
3211
    @media (prefers-reduced-motion: reduce) {
3212
      .chat-rail {
3213
        transition: none;
3214
      }
3215
    }
3216
3217
    /* Below this width each projection has its own placement, so showing these
3218
       too would state the same thing twice. */
3219
    .chat-sidebar-work,
3220
    .delegation-inline {
3221
      display: none;
3222
    }
3223
  }
3224
3225
  .chat-rail__header {
3226
    display: flex;
3227
    flex: none;
3228
    align-items: center;
3229
    justify-content: space-between;
3230
    gap: 8px;
3231
    border-bottom: 1px solid var(--line);
3232
    padding: 8px 8px 8px 16px;
3233
  }
3234
3235
  .chat-rail__label {
3236
    overflow: hidden;
3237
    color: var(--text-muted);
3238
    font-size: 0.6875rem;
3239
    font-weight: 600;
3240
    letter-spacing: 0.06em;
3241
    text-transform: uppercase;
3242
    white-space: nowrap;
3243
  }
3244
3245
  .chat-rail[data-collapsed="true"] .chat-rail__label {
3246
    display: none;
3247
  }
3248
3249
  .chat-rail[data-collapsed="true"] .chat-rail__header {
3250
    justify-content: center;
3251
    padding-inline: 8px;
3252
  }
3253
3254
  .chat-rail__body {
3255
    display: flex;
3256
    min-height: 0;
3257
    flex: 1;
3258
    flex-direction: column;
3259
    gap: 20px;
3260
    overflow-y: auto;
3261
    overscroll-behavior: contain;
3262
    padding: 12px 0;
3263
  }
3264
3265
  .chat-rail[data-collapsed="true"] .chat-rail__body {
3266
    display: none;
3267
  }
3268
3269
  .chat-rail__glyph-collapse,
3270
  .chat-rail[data-collapsed="true"] .chat-rail__glyph-expand {
3271
    display: inline-flex;
3272
  }
3273
3274
  .chat-rail__glyph-expand,
3275
  .chat-rail[data-collapsed="true"] .chat-rail__glyph-collapse {
3276
    display: none;
3277
  }
3278
3279
  .rail-section {
3280
    display: flex;
3281
    min-width: 0;
3282
    flex-direction: column;
3283
    gap: 8px;
3284
  }
3285
3286
  /* 20px is where a sidebar row's text starts (12px margin plus 8px padding),
3287
     so the heading, the work rows and the delegation below them all sit on one
3288
     left edge while the rows keep the wider hit area their wash draws. */
3289
  .rail-section__label {
3290
    margin-inline: 20px;
3291
    color: var(--text-dim);
3292
    font-size: 0.6875rem;
3293
    font-weight: 600;
3294
    letter-spacing: 0.06em;
3295
    text-transform: uppercase;
3296
  }
3297
3298
  .delegation-rail > :not(.rail-section__label) {
3299
    margin-inline: 20px;
3300
  }
3301
}
3302
3303
/* ── Delegation projection ────────────────────────────────────────────────── */
3304
3305
/* One projection with two placements: the rail beside the transcript on wide
3306
 * screens, the same content inline at the transcript tail below that. Both are
3307
 * ephemeral -- present only while a delegation is live or recently finished. */
3308
3309
@layer components {
3310
  .delegation-inline {
3311
    width: 100%;
3312
    max-width: 46rem;
3313
    margin-inline: auto;
3314
    border: 1px solid var(--line);
3315
    border-radius: var(--radius-lg);
3316
    background: var(--ink-surface);
3317
    padding: 12px;
3318
  }
3319
3320
  .delegation-inline__details {
3321
    display: flex;
3322
    flex-direction: column;
3323
    gap: 8px;
3324
  }
3325
3326
  .delegation-inline__outcome {
3327
    color: var(--text-muted);
3328
    font-size: 0.8125rem;
3329
  }
3330
3331
  .delegation-live {
3332
    display: flex;
3333
    flex-direction: column;
3334
    gap: 6px;
3335
  }
3336
3337
  .delegation-live__header {
3338
    display: flex;
3339
    align-items: center;
3340
    gap: 8px;
3341
  }
3342
3343
  .delegation-live__subject {
3344
    min-width: 0;
3345
    flex: 1;
3346
    overflow: hidden;
3347
    color: var(--text-primary);
3348
    font-size: 0.8125rem;
3349
    font-weight: 500;
3350
    text-overflow: ellipsis;
3351
    white-space: nowrap;
3352
  }
3353
3354
  .delegation-live__computer,
3355
  .delegation-live__elapsed {
3356
    color: var(--text-dim);
3357
    font-family: var(--font-mono, ui-monospace, monospace);
3358
    font-size: 0.75rem;
3359
    font-variant-numeric: tabular-nums;
3360
  }
3361
3362
  .delegation-log {
3363
    display: flex;
3364
    max-height: 40vh;
3365
    flex-direction: column;
3366
    gap: 6px;
3367
    overflow-y: auto;
3368
    overscroll-behavior: contain;
3369
    color: var(--text-muted);
3370
    font-family: var(--font-mono, ui-monospace, monospace);
3371
    font-size: 0.75rem;
3372
    line-height: 1.5;
3373
  }
3374
3375
  /* The log only ever scrolls vertically. Its rows are flex items, which are
3376
     `min-width: auto` by default, so one long command or path made the whole
3377
     column as wide as itself and the panel scrolled sideways instead. */
3378
  .delegation-log > * {
3379
    min-width: 0;
3380
    max-width: 100%;
3381
  }
3382
3383
  /* ── Streamed log rows ───────────────────────────────────────────────────── */
3384
3385
  /* Built by the .DelegationLog hook, which frames the plain text stream into
3386
     prose, tool cards and notes. The hook owns the DOM; these are the only
3387
     rules it relies on. */
3388
3389
  .deleg-prose {
3390
    overflow-wrap: anywhere;
3391
    /* The stream carries its own line breaks, and they are the shape of what
3392
       the agent said; the rest wraps to the rail's width. */
3393
    white-space: pre-wrap;
3394
  }
3395
3396
  .deleg-tool {
3397
    border: 1px solid var(--line);
3398
    border-radius: var(--radius-sm);
3399
    background: var(--ink-raised);
3400
  }
3401
3402
  .deleg-tool__summary {
3403
    display: flex;
3404
    align-items: center;
3405
    gap: 8px;
3406
    padding: 5px 8px;
3407
    color: var(--text-muted);
3408
    cursor: pointer;
3409
    list-style: none;
3410
  }
3411
3412
  .deleg-tool__summary::-webkit-details-marker {
3413
    display: none;
3414
  }
3415
3416
  .deleg-tool__dot {
3417
    width: 6px;
3418
    height: 6px;
3419
    flex: none;
3420
    border-radius: 9999px;
3421
    background: var(--info);
3422
  }
3423
3424
  .deleg-tool[data-status="succeeded"] .deleg-tool__dot {
3425
    background: var(--success);
3426
  }
3427
3428
  .deleg-tool[data-status="failed"] .deleg-tool__dot {
3429
    background: var(--danger);
3430
  }
3431
3432
  .deleg-tool__label {
3433
    flex: none;
3434
    color: var(--text-body);
3435
  }
3436
3437
  /* The command as a one-line preview beside its label. It is the detail that
3438
     tells two `Terminal` steps apart, so it is stated on the closed card and
3439
     truncated rather than wrapped. */
3440
  .deleg-tool__inline {
3441
    min-width: 0;
3442
    flex: 1;
3443
    overflow: hidden;
3444
    color: var(--text-dim);
3445
    text-overflow: ellipsis;
3446
    white-space: nowrap;
3447
  }
3448
3449
  .deleg-tool__body {
3450
    display: flex;
3451
    flex-direction: column;
3452
    gap: 6px;
3453
    border-top: 1px solid var(--line);
3454
    padding: 8px;
3455
  }
3456
3457
  .deleg-tool__cmd,
3458
  .deleg-tool__out {
3459
    max-height: 16rem;
3460
    overflow: auto;
3461
    overflow-wrap: anywhere;
3462
    font: inherit;
3463
    white-space: pre-wrap;
3464
  }
3465
3466
  .deleg-tool__cmd {
3467
    color: var(--text-body);
3468
  }
3469
3470
  .deleg-tool__out {
3471
    color: var(--text-muted);
3472
  }
3473
3474
  .deleg-note {
3475
    overflow-wrap: anywhere;
3476
    color: var(--text-dim);
3477
  }
3478
3479
  .deleg-note[data-tone="warn"] {
3480
    color: var(--warning);
3481
  }
3482
3483
  .deleg-note[data-tone="error"] {
3484
    color: var(--danger);
3485
  }
3486
3487
  .delegation-truncated {
3488
    color: var(--text-dim);
3489
    font-size: 0.6875rem;
3490
  }
3491
3492
  /* Dot, two lines, and an optional trailing control on one row -- the same
3493
     shape as the work rows above it, so the rail reads as one list. */
3494
  .delegation-summary {
3495
    display: flex;
3496
    min-width: 0;
3497
    align-items: center;
3498
    gap: 8px;
3499
  }
3500
3501
  .delegation-summary__lines {
3502
    display: flex;
3503
    min-width: 0;
3504
    flex-direction: column;
3505
  }
3506
3507
  .delegation-summary__title {
3508
    overflow: hidden;
3509
    color: var(--text-muted);
3510
    font-size: 0.8125rem;
3511
    text-overflow: ellipsis;
3512
    white-space: nowrap;
3513
  }
3514
3515
  .delegation-summary__meta {
3516
    color: var(--text-dim);
3517
    font-size: 0.75rem;
3518
  }
3519
3520
  .delegation-summary__dot {
3521
    flex: none;
3522
  }
3523
3524
  /* A superseded summary is kept rather than removed -- it happened -- but it
3525
     is stated more quietly than the one that replaced it. */
3526
  .delegation-summary--superseded {
3527
    opacity: 0.6;
3528
  }
3529
3530
  .delegation-summary__dismiss {
3531
    flex: none;
3532
    margin-inline-start: auto;
3533
  }
3534
}
3535
3536 3125
/* ── Composer and transcript tails ────────────────────────────────────────── */
3537 3126
3538 3127
/* Send, stop, and end-a-call are `prompt_input_submit/1` and
lib/openagents/computer/acp_transcript.ex modified +2 -2

@@ -2,8 +2,8 @@ defmodule OpenAgents.Computer.AcpTranscript do

2 2
  @moduledoc """
3 3
  Decodes the controller's framed ACP progress stream into a human transcript.
4 4
5
  The live rail (`.DelegationLog` in `chat_live.ex`) already splits this wire
6
  format — record separator `0x1E` starts a frame, fields split on unit
5
  The wire format frames structured tool events inside the plain text stream:
6
  record separator `0x1E` starts a frame, and its fields split on unit
7 7
  separator `0x1F`. Durable job reports used to post the raw bytes. This
8 8
  module is the server-side decoder so a timeout or completion never writes
9 9
  `Ttoolu_…0executeVGVybWluYWw=` into the conversation.
lib/openagents/computer_activity.ex modified +6 -5

@@ -5,11 +5,12 @@ defmodule OpenAgents.ComputerActivity do

5 5
  While `OpenAgents.Computer` collects a delegation's streamed output, this module
6 6
  re-broadcasts a bounded projection of the stream over PubSub on the computer
7 7
  owner's conversation topic — the delegation start, each bounded chunk, and
8
  the typed terminal — so the chat interface can show the remote agent working
9
  while it works. PubSub is a low-latency projection and never the data
10
  authority: nothing broadcast here is persisted, a missed broadcast loses
11
  nothing durable, and the durable record remains the terminal tool-step
12
  outcome. A reload mid-delegation degrades to status-only.
8
  the typed terminal — so a surface can show the remote agent working while it
9
  works. No surface subscribes today: `/chat` is zero-based and states no live
10
  delegation, so the projection currently runs with no consumer beyond its own
11
  tests. PubSub is a low-latency projection and never the data authority:
12
  nothing broadcast here is persisted, a missed broadcast loses nothing
13
  durable, and the durable record remains the terminal tool-step outcome.
13 14
14 15
  The topic is owner-scoped by construction: Sarah is a one-conversation
15 16
  product, delegations require the `browser_conversation` scope of the computer
lib/openagents/conversations.ex modified -55

@@ -844,61 +844,6 @@ defmodule OpenAgents.Conversations do

844 844
    )
845 845
  end
846 846
847
  @doc """
848
  Deep-work rollups for a page of the transcript, keyed by the report message
849
  the job produced.
850
851
  A `work_jobs` report message renders as a "Worked for <duration>" event
852
  header, so the transcript needs the job's terminal shape — status, duration,
853
  and step-outcome counts — as a read-only projection beside the message. Only
854
  bounded durable fields are selected; a job that has not completed carries no
855
  rollup and its report renders as an ordinary message.
856
  """
857
  def list_work_job_rollups_by_message(messages) when is_list(messages) do
858
    job_ids =
859
      messages
860
      |> Enum.filter(&(&1.role == "assistant" and is_binary(&1.work_job_id)))
861
      |> Enum.map(&{&1.work_job_id, &1.id})
862
      |> Map.new()
863
864
    case Map.keys(job_ids) do
865
      [] ->
866
        %{}
867
868
      ids ->
869
        step_counts =
870
          from(step in OpenAgents.Work.JobStep,
871
            where: step.work_job_id in ^ids,
872
            group_by: [step.work_job_id, step.status],
873
            select: {step.work_job_id, step.status, count(step.id)}
874
          )
875
          |> Repo.all()
876
          |> Enum.group_by(&elem(&1, 0), fn {_job_id, status, count} -> {status, count} end)
877
          |> Map.new(fn {job_id, pairs} -> {job_id, Map.new(pairs)} end)
878
879
        from(job in OpenAgents.Work.Job,
880
          where: job.id in ^ids and not is_nil(job.completed_at),
881
          select: %{
882
            id: job.id,
883
            status: job.status,
884
            error_code: job.error_code,
885
            tool_call_count: job.tool_call_count,
886
            started_at: job.started_at,
887
            completed_at: job.completed_at
888
          }
889
        )
890
        |> Repo.all()
891
        |> Map.new(fn job ->
892
          counts = Map.get(step_counts, job.id, %{})
893
894
          {Map.fetch!(job_ids, job.id),
895
           job
896
           |> Map.put(:succeeded_count, Map.get(counts, "succeeded", 0))
897
           |> Map.put(:refused_count, Map.get(counts, "refused", 0))}
898
        end)
899
    end
900
  end
901
902 847
  def append_assistant_delta(%Turn{} = turn, delta) when is_binary(delta) do
903 848
    maximum_bytes = Application.fetch_env!(:openagents, :maximum_message_bytes)
904 849
lib/openagents/work.ex modified +3 -2

@@ -247,8 +247,9 @@ defmodule OpenAgents.Work do

247 247
  @doc """
248 248
  The most recent jobs for a conversation, newest first, bounded.
249 249
250
  A read-only sidebar projection over the durable job rows; live updates ride
251
  the existing `{:work_job_updated, job}` broadcasts rather than polling.
250
  A read-only projection over the durable job rows, read by the tools that
251
  answer "what have you been working on"; live consumers ride the existing
252
  `{:work_job_updated, job}` broadcasts rather than polling.
252 253
  """
253 254
  def recent_jobs(%Conversation{id: conversation_id}, limit)
254 255
      when is_integer(limit) and limit > 0 do
lib/openagents_web/live/chat_live.ex modified +8 -798

@@ -12,11 +12,9 @@ defmodule OpenAgentsWeb.ChatLive do

12 12
  }
13 13
14 14
  alias OpenAgents.Analytics.Chat, as: ChatAnalytics
15
  alias OpenAgents.ComputerActivity
16 15
  alias OpenAgents.Conversations.Message
17 16
  alias OpenAgents.Voice.Config, as: VoiceConfig
18 17
  alias OpenAgents.Voice.Recordings
19
  alias OpenAgents.Work
20 18
  alias OpenAgentsWeb.ToolActivity
21 19
  alias OpenAgentsWeb.UI
22 20

@@ -59,16 +57,6 @@ defmodule OpenAgentsWeb.ChatLive do

59 57
60 58
  import OpenAgentsWeb.AI.Reasoning, only: [tool: 1, tool_header: 1, tool_content: 1]
61 59
62
  # The sidebar's calls and work sections are bounded projections, not
63
  # unbounded lists: the last eight of each, recomputed on the same PubSub
64
  # broadcasts that drive the transcript.
65
  @sidebar_section_limit 8
66
67
  # One live delegation panel at a time: a newer delegation supersedes the
68
  # current one, which collapses to a summary line. Superseded summaries are a
69
  # bounded ephemeral list, cleared on dismiss.
70
  @delegation_summary_limit 3
71
72 60
  @impl true
73 61
  def mount(_params, _session, %{assigns: %{current_user: current_user}} = socket) do
74 62
    {:ok, conversation} = Conversations.ensure_conversation(current_user)

@@ -82,8 +70,6 @@ defmodule OpenAgentsWeb.ChatLive do

82 70
      :ok = Conversations.subscribe(conversation)
83 71
      :ok = ProfileMemory.subscribe(owner)
84 72
      :ok = Voice.subscribe(conversation)
85
      :ok = Work.subscribe(conversation.id)
86
      :ok = ComputerActivity.subscribe(conversation.id)
87 73
88 74
      Analytics.capture("chat_opened", Analytics.distinct_id(current_user))
89 75
    end

@@ -101,17 +87,12 @@ defmodule OpenAgentsWeb.ChatLive do

101 87
      |> assign(:voice_enabled?, voice_config.enabled?)
102 88
      |> assign(:recording_config, Recordings.config())
103 89
      |> assign(:voice_session, voice_session)
104
      |> assign(:recent_jobs, Work.recent_jobs(conversation, @sidebar_section_limit))
105 90
      |> assign(:tool_activity, tool_activity(active_turn, voice_session))
106 91
      |> assign(:message_activity, message_activity(messages))
107
      |> assign(:job_rollups, Conversations.list_work_job_rollups_by_message(messages))
108 92
      |> assign(:composer_error, nil)
109 93
      |> assign(:form, composer_form())
110 94
      |> assign(:live_voice_items, MapSet.new())
111 95
      |> assign(:paced_voice_items, MapSet.new())
112
      |> assign(:delegation, nil)
113
      |> assign(:delegation_summaries, [])
114
      |> assign(:rail_collapsed, false)
115 96
      |> stream(:messages, messages)
116 97
117 98
    {:ok, socket}

@@ -134,11 +115,6 @@ defmodule OpenAgentsWeb.ChatLive do

134 115
    {:noreply, socket}
135 116
  end
136 117
137
  def handle_event("cancel_delegation", _params, socket) do
138
    _cancel = OpenAgents.Work.cancel_active_delegations(socket.assigns.conversation.id)
139
    {:noreply, socket}
140
  end
141
142 118
  # Drop a not-yet-started queued message before it runs.
143 119
  def handle_event("dequeue_message", %{"id" => id}, socket) do
144 120
    id = String.to_integer(id)

@@ -162,42 +138,16 @@ defmodule OpenAgentsWeb.ChatLive do

162 138
        :message_activity,
163 139
        Map.merge(socket.assigns.message_activity, message_activity(messages))
164 140
      )
165
      |> assign(
166
        :job_rollups,
167
        Map.merge(
168
          socket.assigns.job_rollups,
169
          Conversations.list_work_job_rollups_by_message(messages)
170
        )
171
      )
172 141
173 142
    {:noreply, socket}
174 143
  end
175 144
176
  # The live delegation panel is ephemeral: dismissing it clears the whole
177
  # projection. The durable event header in the transcript stays the record.
178
  def handle_event("dismiss_delegation", _params, socket) do
179
    {:noreply,
180
     socket
181
     |> assign(:delegation, nil)
182
     |> assign(:delegation_summaries, [])}
183
  end
184
185
  # Collapse state is a server assign, not a client attribute toggle: the rail
186
  # re-renders on every streamed chunk, so a DOM-only `data-collapsed` snapped
187
  # back open on the next patch. Holding it here keeps the rail collapsed until
188
  # the reader expands it again. It is deliberately not persisted: the rail is
189
  # a view of what is happening now, so every visit starts open.
190
  def handle_event("toggle_rail", _params, socket) do
191
    {:noreply, assign(socket, :rail_collapsed, !socket.assigns.rail_collapsed)}
192
  end
193
194 145
  @impl true
195 146
  def handle_info({:message_updated, message}, socket) do
196 147
    {:noreply,
197 148
     socket
198 149
     |> capture_assistant_message(message)
199 150
     |> clear_live_voice_item(message.provider_item_id)
200
     |> refresh_job_rollup(message)
201 151
     |> stream_insert(:messages, message)}
202 152
  end
203 153

@@ -296,107 +246,6 @@ defmodule OpenAgentsWeb.ChatLive do

296 246
297 247
  def handle_info({:voice_session_updated, _other_session}, socket), do: {:noreply, socket}
298 248
299
  # Job lifecycle broadcasts (create, running, terminal) refresh the sidebar's
300
  # bounded work section; the terminal broadcast also carries the report
301
  # message id that turns a row into a jump-to-report anchor.
302
  def handle_info(
303
        {:work_job_updated, %{conversation_id: conversation_id}},
304
        %{assigns: %{conversation: %{id: conversation_id}}} = socket
305
      ) do
306
    {:noreply,
307
     assign(
308
       socket,
309
       :recent_jobs,
310
       Work.recent_jobs(socket.assigns.conversation, @sidebar_section_limit)
311
     )}
312
  end
313
314
  def handle_info({:work_job_updated, _other_job}, socket), do: {:noreply, socket}
315
316
  # ── Live delegation projection (OpenAgents.ComputerActivity) ────────────────────
317
  # A bounded ephemeral projection of one streamed computer delegation. Only
318
  # one live panel at a time: a newer delegation supersedes the current one,
319
  # which collapses to a bounded summary line. Nothing here is persisted; the
320
  # durable tool-step outcome remains the record, and a reload mid-delegation
321
  # degrades to the quiet tool-activity status (chunks with no matching start
322
  # are ignored rather than reconstructed).
323
324
  def handle_info({:computer_live_started, event}, socket) do
325
    summaries =
326
      case socket.assigns.delegation do
327
        nil ->
328
          socket.assigns.delegation_summaries
329
330
        superseded ->
331
          Enum.take(
332
            [delegation_summary(superseded) | socket.assigns.delegation_summaries],
333
            @delegation_summary_limit
334
          )
335
      end
336
337
    {:noreply,
338
     socket
339
     |> assign(:delegation, %{
340
       ref: event.ref,
341
       kind: event.kind,
342
       machine_name: event.machine_name,
343
       agent_id: event.agent_id,
344
       started_at: event.started_at,
345
       state: :running,
346
       status: "running",
347
       stop_reason: "",
348
       duration_ms: nil,
349
       truncated?: false
350
     })
351
     |> assign(:delegation_summaries, summaries)}
352
  end
353
354
  # Chunk text never enters an assign: it is pushed to the log hooks, which
355
  # append it client-side. The server-side caps in OpenAgents.ComputerActivity
356
  # bound what can ever arrive here.
357
  def handle_info({:computer_live_chunk, %{ref: ref, text: text}}, socket) do
358
    case socket.assigns.delegation do
359
      %{ref: ^ref, state: :running} ->
360
        {:noreply, push_event(socket, "delegation:chunk", %{ref: ref, text: text})}
361
362
      _stale_or_absent ->
363
        {:noreply, socket}
364
    end
365
  end
366
367
  def handle_info({:computer_live_truncated, %{ref: ref}}, socket) do
368
    case socket.assigns.delegation do
369
      %{ref: ^ref} = delegation ->
370
        {:noreply, assign(socket, :delegation, %{delegation | truncated?: true})}
371
372
      _stale_or_absent ->
373
        {:noreply, socket}
374
    end
375
  end
376
377
  def handle_info({:computer_live_terminal, %{ref: ref} = event}, socket) do
378
    case socket.assigns.delegation do
379
      %{ref: ^ref} = delegation ->
380
        {:noreply,
381
         assign(socket, :delegation, %{
382
           delegation
383
           | state: :terminal,
384
             status: event.status,
385
             stop_reason: event.stop_reason,
386
             duration_ms: event.duration_ms
387
         })}
388
389
      _superseded_or_absent ->
390
        summaries =
391
          Enum.map(socket.assigns.delegation_summaries, fn
392
            %{ref: ^ref} = summary -> %{summary | status: event.status}
393
            summary -> summary
394
          end)
395
396
        {:noreply, assign(socket, :delegation_summaries, summaries)}
397
    end
398
  end
399
400 249
  def handle_info({:voice_tool_activity_updated, session_id, _step_id}, socket) do
401 250
    case socket.assigns.voice_session do
402 251
      %{id: ^session_id} = session when is_nil(socket.assigns.active_turn) ->

@@ -773,41 +622,6 @@ defmodule OpenAgentsWeb.ChatLive do

773 622
  defp status_label("cancelled"), do: "STOPPED"
774 623
  defp status_label(_status), do: nil
775 624
776
  # A deep-work report message just landed: pull its bounded rollup projection
777
  # so the row can render as a "Worked for <duration>" header.
778
  defp refresh_job_rollup(socket, %Message{work_job_id: job_id} = message)
779
       when is_binary(job_id) do
780
    assign(
781
      socket,
782
      :job_rollups,
783
      Map.merge(
784
        socket.assigns.job_rollups,
785
        Conversations.list_work_job_rollups_by_message([message])
786
      )
787
    )
788
  end
789
790
  defp refresh_job_rollup(socket, _message), do: socket
791
792
  defp rollup_title(%{started_at: started_at, completed_at: completed_at}) do
793
    case ToolActivity.duration(started_at, completed_at) do
794
      nil -> "Worked"
795
      duration -> "Worked for #{duration}"
796
    end
797
  end
798
799
  # Job statuses mapped onto the existing status hues; never hidden.
800
  defp rollup_status(%{status: "completed"}), do: "succeeded"
801
  defp rollup_status(%{status: "failed"}), do: "failed"
802
  defp rollup_status(%{status: "interrupted"}), do: "interrupted"
803
  defp rollup_status(%{status: "budget_exhausted"}), do: "unavailable"
804
  defp rollup_status(_rollup), do: "unavailable"
805
806
  defp rollup_status_note(%{status: "completed"}), do: nil
807
808
  defp rollup_status_note(%{status: status}),
809
    do: status |> String.upcase() |> String.replace("_", " ")
810
811 625
  defp message_status_variant("streaming"), do: :info
812 626
  defp message_status_variant(_status), do: :warning
813 627

@@ -845,11 +659,7 @@ defmodule OpenAgentsWeb.ChatLive do

845 659
      </:title_menu>
846 660
847 661
      <:sidebar_extra>
848
        <.chat_sidebar_rows
849
          current_user={@current_user}
850
          reset_enabled?={@reset_enabled?}
851
          recent_jobs={@recent_jobs}
852
        />
662
        <.chat_sidebar_rows current_user={@current_user} reset_enabled?={@reset_enabled?} />
853 663
      </:sidebar_extra>
854 664
855 665
      <div id="openagents-app" class="chat-shell">

@@ -898,7 +708,6 @@ defmodule OpenAgentsWeb.ChatLive do

898 708
                    message={message}
899 709
                    paced_items={@paced_voice_items}
900 710
                    activity={Map.get(@message_activity, message.id, [])}
901
                    rollup={Map.get(@job_rollups, message.id)}
902 711
                  />
903 712
                </div>
904 713

@@ -921,12 +730,6 @@ defmodule OpenAgentsWeb.ChatLive do

921 730
                    activity={activity}
922 731
                  />
923 732
                </section>
924
925
                <%!-- Below the desktop breakpoint the live delegation projection
926
                      renders inline at the transcript tail on the event-header
927
                      expansion pattern, instead of as a rail. One projection,
928
                      two placements; the stylesheet shows exactly one. --%>
929
                <.delegation_inline :if={false and @delegation} delegation={@delegation} />
930 733
              </.conversation_content>
931 734
            </.conversation>
932 735
          </div>

@@ -967,20 +770,6 @@ defmodule OpenAgentsWeb.ChatLive do

967 770
            />
968 771
          </footer>
969 772
        </main>
970
971
        <%!-- Desktop only (≥1280px): the work rail, the shell's second column.
972
              The chat column keeps one job — transcript above, composer pinned
973
              under it — and everything about running work moves out here, where
974
              a streamed log has the width to be read. Below the breakpoint the
975
              rail is gone and the same two projections render in the navigation
976
              sidebar and at the transcript tail. --%>
977
        <.chat_rail
978
          :if={false and (@recent_jobs != [] or @delegation != nil or @delegation_summaries != [])}
979
          recent_jobs={@recent_jobs}
980
          delegation={@delegation}
981
          summaries={@delegation_summaries}
982
          collapsed={@rail_collapsed}
983
        />
984 773
      </div>
985 774
986 775
      <script :type={Phoenix.LiveView.ColocatedHook} name=".TranscriptScroll">

@@ -1112,178 +901,6 @@ defmodule OpenAgentsWeb.ChatLive do

1112 901
        }
1113 902
      </script>
1114 903
1115
      <script :type={Phoenix.LiveView.ColocatedHook} name=".DelegationLog">
1116
        // Record/unit separators frame structured tool events inside the plain
1117
        // text stream (see sarah-computer-controller AcpAgent.renderUpdate).
1118
        // A frame is one line beginning with RS whose fields split on US;
1119
        // everything else is agent prose. This hook renders frames as
1120
        // collapsible tool cards and notes, and prose as text — never HTML.
1121
        const RS = String.fromCharCode(30)
1122
        const US = String.fromCharCode(31)
1123
1124
        const decode64 = (value) => {
1125
          try {
1126
            return new TextDecoder().decode(Uint8Array.from(atob(value), (c) => c.charCodeAt(0)))
1127
          } catch {
1128
            return ""
1129
          }
1130
        }
1131
1132
        export default {
1133
          mounted() {
1134
            this.buffer = ""
1135
            this.tools = new Map()
1136
            this.prose = null
1137
            this.handleEvent("delegation:chunk", ({ ref, text }) => {
1138
              if (this.el.dataset.ref !== ref) return
1139
              const follow =
1140
                this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight < 40
1141
              this.ingest(text)
1142
              if (follow) this.el.scrollTop = this.el.scrollHeight
1143
            })
1144
          },
1145
1146
          // Flush prose immediately; hold only an incomplete trailing frame.
1147
          ingest(text) {
1148
            this.buffer += text
1149
            while (this.buffer.length > 0) {
1150
              if (this.buffer[0] === RS) {
1151
                const nl = this.buffer.indexOf("\n")
1152
                if (nl === -1) break
1153
                const line = this.buffer.slice(1, nl)
1154
                this.buffer = this.buffer.slice(nl + 1)
1155
                this.renderFrame(line)
1156
              } else {
1157
                const next = this.buffer.indexOf(RS)
1158
                const chunk = next === -1 ? this.buffer : this.buffer.slice(0, next)
1159
                this.buffer = next === -1 ? "" : this.buffer.slice(next)
1160
                this.appendProse(chunk)
1161
              }
1162
            }
1163
          },
1164
1165
          appendProse(text) {
1166
            if (text === "") return
1167
            if (!this.prose || this.prose.parentNode !== this.el || this.el.lastChild !== this.prose) {
1168
              this.prose = document.createElement("span")
1169
              this.prose.className = "deleg-prose"
1170
              this.el.appendChild(this.prose)
1171
            }
1172
            this.prose.appendChild(document.createTextNode(text))
1173
          },
1174
1175
          renderFrame(line) {
1176
            const fields = line.split(US)
1177
            if (fields[0] === "T") {
1178
              this.renderTool(fields)
1179
            } else if (fields[0] === "N") {
1180
              this.renderNote(fields)
1181
            }
1182
            this.prose = null
1183
          },
1184
1185
          // T | id | phase(0 start,1 done,2 failed) | kind | b64 title | b64 detail
1186
          renderTool([, id, phase, kind, b64title, b64detail]) {
1187
            const title = decode64(b64title || "")
1188
            const detail = decode64(b64detail || "")
1189
            let card = this.tools.get(id)
1190
            if (!card) {
1191
              card = this.buildTool(id, kind, title, detail)
1192
              this.tools.set(id, card)
1193
              this.el.appendChild(card.root)
1194
            }
1195
            if (phase === "1" || phase === "2") {
1196
              card.root.dataset.status = phase === "1" ? "succeeded" : "failed"
1197
              if (detail !== "") {
1198
                card.out.textContent = detail
1199
                card.out.hidden = false
1200
              }
1201
            }
1202
          },
1203
1204
          buildTool(id, kind, title, command) {
1205
            const root = document.createElement("details")
1206
            root.className = "deleg-tool"
1207
            root.dataset.status = "running"
1208
            root.dataset.kind = kind || "other"
1209
1210
            const summary = document.createElement("summary")
1211
            summary.className = "deleg-tool__summary"
1212
            const dot = document.createElement("span")
1213
            dot.className = "deleg-tool__dot"
1214
            const label = document.createElement("span")
1215
            label.className = "deleg-tool__label"
1216
            label.textContent = this.kindLabel(kind) || title || "tool"
1217
            summary.appendChild(dot)
1218
            summary.appendChild(label)
1219
            if (command !== "") {
1220
              const inline = document.createElement("code")
1221
              inline.className = "deleg-tool__inline"
1222
              inline.textContent = command
1223
              summary.appendChild(inline)
1224
            }
1225
1226
            const body = document.createElement("div")
1227
            body.className = "deleg-tool__body"
1228
            const cmd = document.createElement("pre")
1229
            cmd.className = "deleg-tool__cmd"
1230
            cmd.textContent = command !== "" ? "$ " + command : title
1231
            const out = document.createElement("pre")
1232
            out.className = "deleg-tool__out"
1233
            out.hidden = true
1234
            body.appendChild(cmd)
1235
            body.appendChild(out)
1236
1237
            root.appendChild(summary)
1238
            root.appendChild(body)
1239
            return { root, out }
1240
          },
1241
1242
          kindLabel(kind) {
1243
            const labels = {
1244
              execute: "Terminal",
1245
              read: "Read",
1246
              edit: "Edit",
1247
              search: "Search",
1248
              fetch: "Fetch",
1249
              think: "Think",
1250
              move: "Move",
1251
              delete: "Delete"
1252
            }
1253
            return labels[kind] || ""
1254
          },
1255
1256
          // N | b64 text | tone(info|warn|error)
1257
          renderNote([, b64text, tone]) {
1258
            const note = document.createElement("div")
1259
            note.className = "deleg-note"
1260
            note.dataset.tone = tone || "info"
1261
            note.textContent = decode64(b64text || "")
1262
            this.el.appendChild(note)
1263
          }
1264
        }
1265
      </script>
1266
1267
      <script :type={Phoenix.LiveView.ColocatedHook} name=".DelegationClock">
1268
        export default {
1269
          // Elapsed time ticks client-side from the broadcast start instant,
1270
          // so the server never re-renders just to move a clock.
1271
          mounted() {
1272
            this.tick()
1273
            this.timer = setInterval(() => this.tick(), 1000)
1274
          },
1275
          destroyed() { clearInterval(this.timer) },
1276
          tick() {
1277
            const started = Date.parse(this.el.dataset.startedAt)
1278
            if (Number.isNaN(started)) return
1279
            const total = Math.max(0, Math.floor((Date.now() - started) / 1000))
1280
            const minutes = Math.floor(total / 60)
1281
            const seconds = total % 60
1282
            this.el.textContent = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
1283
          }
1284
        }
1285
      </script>
1286
1287 904
      <script :type={Phoenix.LiveView.ColocatedHook} name=".LocalTime">
1288 905
        export default {
1289 906
          // Server timestamps render in UTC; the browser is the only place

@@ -1306,36 +923,15 @@ defmodule OpenAgentsWeb.ChatLive do

1306 923
1307 924
  attr :current_user, :map, required: true
1308 925
  attr :reset_enabled?, :boolean, required: true
1309
  attr :recent_jobs, :list, required: true
1310 926
1311 927
  # The conversation's own rows, contributed to the application sidebar. The
1312
  # shared destinations live there directly; what is left here is the work
1313
  # projection and the conversation's data action. Rows are the
1314
  # stretched-anchor pattern: the hit control owns the whole row and the
1315
  # accessible name, the visible content beneath is pointer-transparent, and
1316
  # any future trailing control floats back above it at its own z-index.
928
  # shared destinations live there directly; what is left here is the
929
  # conversation's data action. Rows are the stretched-anchor pattern: the hit
930
  # control owns the whole row and the accessible name, the visible content
931
  # beneath is pointer-transparent, and any future trailing control floats back
932
  # above it at its own z-index.
1317 933
  defp chat_sidebar_rows(assigns) do
1318 934
    ~H"""
1319
    <%!-- Calls and work: bounded, durable-backed projections (last eight
1320
            each), refreshed by the same PubSub broadcasts that drive the
1321
            transcript. A row whose evidence is a durable transcript message is
1322
            a stretched anchor to it; a row with no target is stated, not
1323
            linked. Empty sections keep their labels and say so honestly. --%>
1324
    <div id="sidebar-sections" class="sidebar-sections">
1325
      <%!-- The narrow-viewport placement of the work projection. Above 1280px
1326
      the work rail beside the transcript states it instead, so the stylesheet
1327
      hides this one; one projection, two placements, exactly one shown. --%>
1328
      <section
1329
        :if={false and @recent_jobs != []}
1330
        id="sidebar-work"
1331
        class="sidebar-section chat-sidebar-work"
1332
        aria-label="Work"
1333
      >
1334
        <h2 class="sidebar-section-label scroll-edge-hairline">WORK</h2>
1335
        <.work_rows id_prefix="sidebar-job" recent_jobs={@recent_jobs} />
1336
      </section>
1337
    </div>
1338
1339 935
    <%!-- Admin moved to the sidebar footer, where it is one row for an
1340 936
    operator on every page rather than a row that only exists on chat. What
1341 937
    stays here is the conversation's own data action. --%>

@@ -1365,358 +961,6 @@ defmodule OpenAgentsWeb.ChatLive do

1365 961
    """
1366 962
  end
1367 963
1368
  attr :id_prefix, :string, required: true
1369
  attr :recent_jobs, :list, required: true
1370
1371
  # The work projection's rows, shared by its two placements. Only the DOM id
1372
  # prefix differs, because both placements are in the document at once and the
1373
  # stylesheet — not the server — decides which one the viewport shows.
1374
  defp work_rows(assigns) do
1375
    ~H"""
1376
    <.sidebar_status_row
1377
      :for={job <- @recent_jobs}
1378
      id={"#{@id_prefix}-#{job.id}"}
1379
      target_message_id={job.report_message_id}
1380
      dot_state={job_dot_state(job)}
1381
      title={job_title(job)}
1382
      meta={job_meta(job)}
1383
      data-status={job.status}
1384
    />
1385
    """
1386
  end
1387
1388
  attr :id, :string, required: true
1389
  attr :target_message_id, :any, default: nil
1390
  attr :dot_state, :string, required: true
1391
  attr :title, :string, required: true
1392
  attr :meta, :string, required: true
1393
  attr :rest, :global
1394
1395
  # Devin's two-line variant on the same __menu-item base: a bounded title line
1396
  # over a text-12 muted meta line, led by an 8px status dot that reinforces —
1397
  # never replaces — the meta words. When the row has durable transcript
1398
  # evidence, the stretched hit target is a plain fragment anchor to that
1399
  # message's DOM id, so the browser scrolls the transcript natively; when it
1400
  # has none, the row is stated rather than dressed up as a link.
1401
  defp sidebar_status_row(assigns) do
1402
    ~H"""
1403
    <div
1404
      id={@id}
1405
      class={[
1406
        "sidebar-row",
1407
        "sidebar-row--two-line",
1408
        is_nil(@target_message_id) && "sidebar-row--static"
1409
      ]}
1410
      {@rest}
1411
    >
1412
      <a
1413
        :if={@target_message_id}
1414
        href={"#messages-#{@target_message_id}"}
1415
        class="sidebar-row__hit"
1416
        aria-label={"#{@title} — #{@meta}"}
1417
      ></a>
1418
      <span class="sidebar-row__content">
1419
        <.status_indicator class="sidebar-row__dot" state={@dot_state} label={@meta} decorative />
1420
        <span class="sidebar-row__lines">
1421
          <span class="sidebar-row__title">{@title}</span>
1422
          <span class="sidebar-row__meta">{@meta}</span>
1423
        </span>
1424
      </span>
1425
    </div>
1426
    """
1427
  end
1428
1429
  # ── Sidebar section row projections ─────────────────────────────────────────
1430
  # The dot vocabulary maps job lifecycle to the existing status hues: blue
1431
  # for a running job, green for completed, gold for interrupted/budget
1432
  # attention, red for failure (DESIGN.md).
1433
  defp job_dot_state(%{status: "completed"}), do: "succeeded"
1434
  defp job_dot_state(%{status: "failed"}), do: "failed"
1435
1436
  defp job_dot_state(%{status: status}) when status in ~w(interrupted budget_exhausted),
1437
    do: "attention"
1438
1439
  defp job_dot_state(_job), do: "running"
1440
1441
  defp job_title(%{goal: goal}) when is_binary(goal) do
1442
    excerpt = String.slice(goal, 0, 80)
1443
    if excerpt == goal, do: goal, else: excerpt <> "…"
1444
  end
1445
1446
  defp job_meta(%{status: "budget_exhausted"}), do: "Budget exhausted"
1447
  defp job_meta(%{status: status}), do: String.capitalize(status)
1448
1449
  # ── Live delegation projection helpers ──────────────────────────────────────
1450
1451
  defp delegation_summary(delegation) do
1452
    %{
1453
      ref: delegation.ref,
1454
      kind: delegation.kind,
1455
      machine_name: delegation.machine_name,
1456
      agent_id: delegation.agent_id,
1457
      status: if(delegation.state == :terminal, do: delegation.status, else: "running")
1458
    }
1459
  end
1460
1461
  # The subject is data, not copy: the delegated agent's id when the
1462
  # controller ran one, otherwise the request kind.
1463
  defp delegation_subject(%{agent_id: agent_id}) when agent_id not in [nil, ""], do: agent_id
1464
  defp delegation_subject(%{kind: kind}), do: kind
1465
1466
  # Controller statuses map onto the step-outcome vocabulary and its hues,
1467
  # exactly as OpenAgents.Tools.ComputerAgent maps the durable outcome: anything
1468
  # unrecognized is stated as failed, never hidden.
1469
  defp delegation_indicator_state("running"), do: "running"
1470
  defp delegation_indicator_state("completed"), do: "succeeded"
1471
  defp delegation_indicator_state("refused"), do: "refused"
1472
  defp delegation_indicator_state("unavailable"), do: "unavailable"
1473
  defp delegation_indicator_state("cancelled"), do: "cancelled"
1474
  defp delegation_indicator_state(_timeout_failed_or_unknown), do: "failed"
1475
1476
  defp delegation_status_word(status),
1477
    do: status |> delegation_indicator_state() |> String.upcase()
1478
1479
  defp delegation_duration(milliseconds) when is_integer(milliseconds) and milliseconds >= 0,
1480
    do: duration_label(div(milliseconds, 1000))
1481
1482
  defp delegation_duration(_unknown), do: nil
1483
1484
  defp duration_label(seconds) when seconds < 0, do: nil
1485
  defp duration_label(seconds) when seconds < 60, do: "#{seconds}s"
1486
1487
  defp duration_label(seconds) when seconds < 3600,
1488
    do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s"
1489
1490
  defp duration_label(seconds), do: "#{div(seconds, 3600)}h #{seconds |> rem(3600) |> div(60)}m"
1491
1492
  attr :recent_jobs, :list, required: true
1493
  attr :delegation, :map, default: nil
1494
  attr :summaries, :list, required: true
1495
  attr :collapsed, :boolean, default: false
1496
1497
  # The work rail: the chat surface's right-hand column on wide screens, and
1498
  # the only place running work is stated there. It carries two projections —
1499
  # the bounded work list, and the live delegation (issue #85) — and neither is
1500
  # chrome or authority: the transcript's durable event headers remain the
1501
  # record. The rail scrolls on its own so the transcript never moves for it,
1502
  # and the rolling log is owned by the .DelegationLog hook
1503
  # (phx-update="ignore"), so chunk text never rides an assign.
1504
  defp chat_rail(assigns) do
1505
    ~H"""
1506
    <aside
1507
      id="chat-rail"
1508
      class="chat-rail"
1509
      data-collapsed={to_string(@collapsed)}
1510
      aria-label="Work"
1511
    >
1512
      <header class="chat-rail__header">
1513
        <h2 class="chat-rail__label">WORK</h2>
1514
        <.button
1515
          id="chat-rail-toggle"
1516
          variant={:ghost}
1517
          size={:sm}
1518
          class="chat-rail__toggle"
1519
          aria-label="Toggle work panel"
1520
          aria-expanded={to_string(!@collapsed)}
1521
          aria-controls="chat-rail-body"
1522
          phx-click="toggle_rail"
1523
        >
1524
          <.icon name="sidebar-collapse-right" class="chat-rail__glyph-collapse" />
1525
          <.icon name="sidebar-open-right" class="chat-rail__glyph-expand" />
1526
        </.button>
1527
      </header>
1528
1529
      <div id="chat-rail-body" class="chat-rail__body">
1530
        <%!-- The wide-viewport placement of the work projection; the navigation
1531
              sidebar states it below the breakpoint. --%>
1532
        <%!-- No accessible name of its own: the rail's own heading already
1533
              names it, and a second "Work" landmark inside a "Work" one only
1534
              adds a level for a screen reader to walk through. --%>
1535
        <section :if={@recent_jobs != []} id="rail-work" class="rail-section">
1536
          <.work_rows id_prefix="rail-job" recent_jobs={@recent_jobs} />
1537
        </section>
1538
1539
        <section
1540
          :if={@delegation != nil or @summaries != []}
1541
          id="delegation-rail"
1542
          class="rail-section delegation-rail"
1543
          aria-label="Live delegation"
1544
        >
1545
          <h3 class="rail-section__label">LIVE DELEGATION</h3>
1546
          <div
1547
            :if={@delegation && @delegation.state == :running}
1548
            id="delegation-live"
1549
            class="delegation-live"
1550
            data-status="running"
1551
          >
1552
            <div class="delegation-live__header">
1553
              <.status_indicator state="running" label="RUNNING" />
1554
              <span class="delegation-live__computer">{@delegation.machine_name}</span>
1555
              <span class="delegation-live__subject">{delegation_subject(@delegation)}</span>
1556
              <time
1557
                id={"delegation-elapsed-#{@delegation.ref}"}
1558
                class="delegation-live__elapsed"
1559
                datetime={DateTime.to_iso8601(@delegation.started_at)}
1560
                data-started-at={DateTime.to_iso8601(@delegation.started_at)}
1561
                phx-hook=".DelegationClock"
1562
                phx-update="ignore"
1563
              ></time>
1564
              <.button
1565
                id="cancel-delegation"
1566
                variant={:ghost}
1567
                size={:xs}
1568
                class="delegation-live__cancel"
1569
                aria-label="Cancel delegation"
1570
                phx-click="cancel_delegation"
1571
              >
1572
                <.icon name="stop" />
1573
              </.button>
1574
            </div>
1575
            <div
1576
              id={"delegation-log-rail-#{@delegation.ref}"}
1577
              class="delegation-log"
1578
              data-ref={@delegation.ref}
1579
              phx-hook=".DelegationLog"
1580
              phx-update="ignore"
1581
            >
1582
            </div>
1583
            <.badge :if={@delegation.truncated?} variant={:dim} class="delegation-truncated">
1584
              TRUNCATED
1585
            </.badge>
1586
          </div>
1587
1588
          <.delegation_summary_row
1589
            :if={@delegation && @delegation.state == :terminal}
1590
            id="delegation-terminal"
1591
            delegation={@delegation}
1592
          >
1593
            <.button
1594
              id="delegation-dismiss"
1595
              variant={:ghost}
1596
              size={:xs}
1597
              class="delegation-summary__dismiss"
1598
              aria-label="Dismiss"
1599
              phx-click="dismiss_delegation"
1600
            >
1601
              <.icon name="x" />
1602
            </.button>
1603
          </.delegation_summary_row>
1604
1605
          <.delegation_summary_row
1606
            :for={summary <- @summaries}
1607
            id={"delegation-summary-#{summary.ref}"}
1608
            delegation={summary}
1609
            class="delegation-summary--superseded"
1610
          />
1611
        </section>
1612
      </div>
1613
    </aside>
1614
    """
1615
  end
1616
1617
  attr :id, :string, required: true
1618
  attr :delegation, :map, required: true
1619
  attr :class, :any, default: nil
1620
  slot :inner_block
1621
1622
  # A finished (or superseded) delegation as one bounded summary line: status
1623
  # dot reinforcing the status word, computer and subject, then stop reason and
1624
  # duration when the terminal carried them.
1625
  defp delegation_summary_row(assigns) do
1626
    ~H"""
1627
    <div
1628
      id={@id}
1629
      class={["delegation-summary", @class]}
1630
      data-status={delegation_indicator_state(@delegation.status)}
1631
    >
1632
      <.status_indicator
1633
        class="delegation-summary__dot"
1634
        state={delegation_indicator_state(@delegation.status)}
1635
        label={delegation_status_word(@delegation.status)}
1636
        decorative
1637
      />
1638
      <span class="delegation-summary__lines">
1639
        <span class="delegation-summary__title">
1640
          {@delegation.machine_name} · {delegation_subject(@delegation)}
1641
        </span>
1642
        <span class="delegation-summary__meta">
1643
          {delegation_status_word(@delegation.status)}<span :if={
1644
            Map.get(@delegation, :stop_reason) not in [nil, ""]
1645
          }> · {@delegation.stop_reason}</span><span :if={
1646
            duration = delegation_duration(Map.get(@delegation, :duration_ms))
1647
          }> · {duration}</span>
1648
        </span>
1649
      </span>
1650
      {render_slot(@inner_block)}
1651
    </div>
1652
    """
1653
  end
1654
1655
  attr :delegation, :map, required: true
1656
1657
  # Narrow-viewport variant of the same projection: an expandable section on
1658
  # the E1 event-header pattern at the transcript tail, with the live log in
1659
  # the expansion. The stylesheet hides it at the desktop breakpoint, where
1660
  # the rail takes over.
1661
  defp delegation_inline(assigns) do
1662
    ~H"""
1663
    <section id="delegation-inline" class="delegation-inline" aria-label="Live delegation">
1664
      <.event_header
1665
        id="delegation-inline-header"
1666
        status={delegation_indicator_state(@delegation.status)}
1667
        title={"#{@delegation.machine_name} · #{delegation_subject(@delegation)}"}
1668
        status_note={
1669
          if(@delegation.state == :terminal, do: delegation_status_word(@delegation.status))
1670
        }
1671
        timestamp={@delegation.started_at}
1672
      >
1673
        <div class="delegation-inline__details">
1674
          <div
1675
            :if={@delegation.state == :running}
1676
            id={"delegation-log-inline-#{@delegation.ref}"}
1677
            class="delegation-log"
1678
            data-ref={@delegation.ref}
1679
            phx-hook=".DelegationLog"
1680
            phx-update="ignore"
1681
          >
1682
          </div>
1683
          <.button
1684
            :if={@delegation.state == :running}
1685
            id="cancel-delegation-inline"
1686
            variant={:ghost}
1687
            size={:xs}
1688
            aria-label="Cancel delegation"
1689
            phx-click="cancel_delegation"
1690
          >
1691
            <.icon name="stop" />
1692
          </.button>
1693
          <.badge :if={@delegation.truncated?} variant={:dim} class="delegation-truncated">
1694
            TRUNCATED
1695
          </.badge>
1696
          <p :if={@delegation.state == :terminal} class="delegation-inline__outcome">
1697
            {delegation_status_word(@delegation.status)}<span :if={
1698
              @delegation.stop_reason not in [nil, ""]
1699
            }> · {@delegation.stop_reason}</span><span :if={
1700
              duration = delegation_duration(@delegation.duration_ms)
1701
            }> · {duration}</span>
1702
          </p>
1703
          <.button
1704
            :if={@delegation.state == :terminal}
1705
            id="delegation-inline-dismiss"
1706
            variant={:ghost}
1707
            size={:xs}
1708
            class="delegation-summary__dismiss"
1709
            aria-label="Dismiss"
1710
            phx-click="dismiss_delegation"
1711
          >
1712
            <.icon name="x" />
1713
          </.button>
1714
        </div>
1715
      </.event_header>
1716
    </section>
1717
    """
1718
  end
1719
1720 964
  attr :id, :string, required: true
1721 965
  attr :activity, :map, required: true
1722 966

@@ -1786,7 +1030,6 @@ defmodule OpenAgentsWeb.ChatLive do

1786 1030
  attr :message, :map, required: true
1787 1031
  attr :paced_items, :any, required: true
1788 1032
  attr :activity, :list, default: []
1789
  attr :rollup, :map, default: nil
1790 1033
1791 1034
  # The transcript's asymmetry carries the roles (DESIGN.md, Message row): a
1792 1035
  # person's message is a right-aligned tinted bubble, Sarah's is bare

@@ -1809,15 +1052,8 @@ defmodule OpenAgentsWeb.ChatLive do

1809 1052
    assigns =
1810 1053
      assigns
1811 1054
      |> assign(:paced?, paced? and written?)
1812
      |> assign(:report?, assigns.rollup != nil and written?)
1813
      |> assign(
1814
        :prose?,
1815
        written? and not paced? and markdown?(assigns.message)
1816
      )
1817
      |> assign(
1818
        :plain?,
1819
        written? and not paced? and not markdown?(assigns.message)
1820
      )
1055
      |> assign(:prose?, written? and not paced? and markdown?(assigns.message))
1056
      |> assign(:plain?, written? and not paced? and not markdown?(assigns.message))
1821 1057
      |> assign(:streaming?, assigns.message.status == "streaming")
1822 1058
1823 1059
    ~H"""

@@ -1888,32 +1124,6 @@ defmodule OpenAgentsWeb.ChatLive do

1888 1124
      <.badge :if={@message.modality == "voice"} variant={:dim} class="message-provenance">
1889 1125
        VOICE TRANSCRIPT{if @message.interrupted, do: " / INTERRUPTED", else: ""}
1890 1126
      </.badge>
1891
      <%!-- A deep-work report renders as a rollup header: how long the job
1892
            worked and how its steps ended, expanding to the report itself. --%>
1893
      <.event_header
1894
        :if={false and @rollup}
1895
        id={"job-rollup-#{@id}"}
1896
        status={rollup_status(@rollup)}
1897
        title={rollup_title(@rollup)}
1898
        status_note={rollup_status_note(@rollup)}
1899
        timestamp={@rollup.completed_at}
1900
        class="job-rollup"
1901
      >
1902
        <:chips>
1903
          <.badge :if={@rollup.succeeded_count > 0} variant={:success}>
1904
            {@rollup.succeeded_count} SUCCEEDED
1905
          </.badge>
1906
          <.badge :if={@rollup.refused_count > 0} variant={:warning}>
1907
            {@rollup.refused_count} REFUSED
1908
          </.badge>
1909
        </:chips>
1910
        <.message_content
1911
          :if={@report?}
1912
          class="message-content message-markdown"
1913
          text={@message.content}
1914
          streaming={@streaming?}
1915
        />
1916
      </.event_header>
1917 1127
      <.message_content
1918 1128
        :if={@prose?}
1919 1129
        class="message-content message-markdown"
test/openagents_web/controllers/computer_agent_jobs_controller_test.exs modified +4 -4

@@ -44,13 +44,13 @@ defmodule OpenAgentsWeb.ComputerAgentJobsControllerTest do

44 44
    assert payload["cwd"] == @root
45 45
    assert payload["prompt"] == "Reply with the word connected and make no changes."
46 46
47
    # `/chat` is zero-based: a running delegation states nothing live there, so
48
    # the API surface is what carries the job while it runs. The absence is
49
    # asserted so a re-introduced live panel is caught here too.
47 50
    _state = :sys.get_state(view.pid)
48
    assert has_element?(view, "#delegation-live", "codex-api-box")
49
    assert has_element?(view, "#delegation-live", "codex")
51
    refute has_element?(view, "#delegation-live")
50 52
51 53
    FakeController.chunk(caller, request_id, "connected")
52
    assert_push_event(view, "delegation:chunk", %{text: "connected"}, 1_000)
53
54 54
    job_ref = monitor_job!(job_id)
55 55
56 56
    FakeController.exit(caller, request_id, %{
test/openagents_web/live/chat_delegation_rail_test.exs deleted -261

@@ -1,261 +0,0 @@

1
defmodule OpenAgentsWeb.ChatDelegationRailTest do
2
  use OpenAgentsWeb.ConnCase
3
  import Phoenix.LiveViewTest
4
5
  alias OpenAgents.{Computer, Conversations, Machines}
6
  alias OpenAgents.Support.FakeController
7
8
  # Mirrors the projection's per-event byte cap; four such chunks reach the
9
  # 65,536-byte cumulative cap.
10
  @maximum_event_bytes 16_384
11
12
  test "no work and no delegation means no rail at all", %{conn: conn} do
13
    conn = log_in_github_user(conn, "delegation-idle-browser")
14
    {:ok, view, _html} = live(conn, ~p"/sarah")
15
16
    refute has_element?(view, "#chat-rail")
17
    refute has_element?(view, "#delegation-rail")
18
    refute has_element?(view, "#delegation-inline")
19
  end
20
21
  test "a streamed delegation renders the rail, the inline panel, and pushes chunks",
22
       %{conn: conn} do
23
    %{conn: conn, machine: machine} =
24
      delegation_owner(conn, "delegation-live-browser", "rail-box")
25
26
    {:ok, view, _html} = live(conn, ~p"/sarah")
27
28
    caller = start_delegation(machine, "claude")
29
    FakeController.chunk(caller.pid, caller.request_id, "hello from the machine")
30
31
    # The chunk rides a push event to the log hooks, never an assign; once it
32
    # arrives, the start event has necessarily been applied too.
33
    assert_push_event(view, "delegation:chunk", %{text: "hello from the machine"}, 1_000)
34
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")
38
    assert has_element?(view, "#delegation-rail #delegation-live[data-status='running']")
39
    assert has_element?(view, "#delegation-live .delegation-live__computer", "rail-box")
40
    assert has_element?(view, "#delegation-live .delegation-live__subject", "claude")
41
    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
    assert has_element?(view, "#delegation-live time[data-started-at]")
44
45
    assert has_element?(
46
             view,
47
             ~s(#chat-rail-toggle[aria-label="Toggle work panel"][aria-controls="chat-rail-body"])
48
           )
49
50
    # Collapse is a server assign so it survives the rail re-rendering on every
51
    # streamed chunk: the toggle flips it and it stays flipped.
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"]))
58
59
    # Narrow-viewport variant: the same projection as an expandable
60
    # event-header section at the transcript tail, live log inside.
61
    assert has_element?(view, "#delegation-inline #delegation-inline-header.event-header")
62
    assert has_element?(view, "#delegation-inline div.delegation-log")
63
64
    FakeController.exit(caller.pid, caller.request_id, %{
65
      "status" => "completed",
66
      "stop_reason" => "end_turn",
67
      "session_id" => "acp-rail-1",
68
      "truncated" => false,
69
      "duration_ms" => 12_000
70
    })
71
72
    assert {:ok, _result} = Task.await(caller.task)
73
74
    # Terminal: the panel collapses to a typed summary line with the status
75
    # word, stop reason, and duration, plus a dismiss control.
76
    assert eventually(fn -> has_element?(view, "#delegation-terminal") end)
77
    refute has_element?(view, "#delegation-live")
78
    assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "SUCCEEDED")
79
    assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "end_turn")
80
    assert has_element?(view, "#delegation-terminal .delegation-summary__meta", "12s")
81
    assert has_element?(view, ~s(#delegation-dismiss[aria-label="Dismiss"]))
82
83
    # Dismissing clears the whole ephemeral projection; the durable event
84
    # header in the transcript remains the record. With no work either, the
85
    # rail has nothing left to state and goes with it.
86
    view |> element("#delegation-dismiss") |> render_click()
87
    refute has_element?(view, "#delegation-rail")
88
    refute has_element?(view, "#delegation-inline")
89
    refute has_element?(view, "#chat-rail")
90
  end
91
92
  test "the capped stream renders an explicit truncation marker", %{conn: conn} do
93
    %{conn: conn, machine: machine} =
94
      delegation_owner(conn, "delegation-truncation-browser", "cap-box")
95
96
    {:ok, view, _html} = live(conn, ~p"/sarah")
97
98
    caller = start_delegation(machine, "claude")
99
    filler = String.duplicate("a", @maximum_event_bytes)
100
101
    for _fill <- 1..4, do: FakeController.chunk(caller.pid, caller.request_id, filler)
102
    FakeController.chunk(caller.pid, caller.request_id, "beyond the cap")
103
104
    assert eventually(fn ->
105
             has_element?(view, "#delegation-rail .delegation-truncated", "TRUNCATED")
106
           end)
107
108
    FakeController.exit(caller.pid, caller.request_id, %{
109
      "status" => "completed",
110
      "stop_reason" => "end_turn",
111
      "truncated" => true,
112
      "duration_ms" => 5
113
    })
114
115
    assert {:ok, _result} = Task.await(caller.task)
116
  end
117
118
  test "a newer delegation supersedes the panel; the older collapses to a summary",
119
       %{conn: conn} do
120
    %{conn: conn, user: user, machine: first_machine} =
121
      delegation_owner(conn, "delegation-supersede-browser", "first-box")
122
123
    second_machine = paired_machine(user, "second-box")
124
    {:ok, view, _html} = live(conn, ~p"/sarah")
125
126
    first = start_delegation(first_machine, "claude")
127
    FakeController.chunk(first.pid, first.request_id, "first delegation working")
128
    assert_push_event(view, "delegation:chunk", %{text: "first delegation working"}, 1_000)
129
    assert has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
130
131
    second = start_delegation(second_machine, "codex")
132
    FakeController.chunk(second.pid, second.request_id, "second delegation working")
133
    assert_push_event(view, "delegation:chunk", %{text: "second delegation working"}, 1_000)
134
135
    # One live panel: the newest delegation owns it; the superseded one is a
136
    # bounded summary line beneath.
137
    assert has_element?(view, "#delegation-live .delegation-live__computer", "second-box")
138
    refute has_element?(view, "#delegation-live .delegation-live__computer", "first-box")
139
    assert has_element?(view, ".delegation-summary--superseded", "first-box")
140
141
    for caller <- [first, second] do
142
      FakeController.exit(caller.pid, caller.request_id, %{
143
        "status" => "completed",
144
        "stop_reason" => "end_turn",
145
        "truncated" => false,
146
        "duration_ms" => 1
147
      })
148
149
      assert {:ok, _result} = Task.await(caller.task)
150
    end
151
152
    # The superseded summary picks up its own terminal status.
153
    assert eventually(fn ->
154
             has_element?(
155
               view,
156
               ".delegation-summary--superseded .delegation-summary__meta",
157
               "SUCCEEDED"
158
             )
159
           end)
160
  end
161
162
  test "another account's conversation never renders the delegation", %{conn: conn} do
163
    %{conn: owner_conn, machine: machine} =
164
      delegation_owner(conn, "delegation-owner-browser", "owned-box")
165
166
    other_conn = log_in_github_user(build_conn(), "delegation-outsider-browser")
167
    {:ok, owner_view, _owner_html} = live(owner_conn, ~p"/sarah")
168
    {:ok, other_view, _other_html} = live(other_conn, ~p"/sarah")
169
170
    caller = start_delegation(machine, "claude")
171
    FakeController.chunk(caller.pid, caller.request_id, "owner-only progress")
172
    assert_push_event(owner_view, "delegation:chunk", %{text: "owner-only progress"}, 1_000)
173
    assert has_element?(owner_view, "#delegation-rail")
174
175
    # The topic is scoped to the owner's conversation, so the other account's
176
    # LiveView structurally never receives the stream.
177
    refute has_element?(other_view, "#delegation-rail")
178
    refute has_element?(other_view, "#delegation-inline")
179
    refute render(other_view) =~ "owner-only progress"
180
181
    FakeController.exit(caller.pid, caller.request_id, %{
182
      "status" => "completed",
183
      "stop_reason" => "end_turn",
184
      "truncated" => false,
185
      "duration_ms" => 1
186
    })
187
188
    assert {:ok, _result} = Task.await(caller.task)
189
  end
190
191
  # Logs the account in, ensures its one conversation exists, and pairs one
192
  # machine so a delegation can target it.
193
  defp delegation_owner(conn, key, machine_name) do
194
    user = github_user(key)
195
    conn = log_in_github_user(conn, key)
196
    {:ok, _conversation} = Conversations.ensure_conversation(user)
197
    %{conn: conn, user: user, machine: paired_machine(user, machine_name)}
198
  end
199
200
  defp paired_machine(user, name) do
201
    {:ok, %{code: code}} =
202
      Machines.start_pairing(%{
203
        "name" => name,
204
        "tier" => "curated",
205
        "platform" => "linux-x64",
206
        "agent_version" => "0.1.0",
207
        "roots" => []
208
      })
209
210
    {:ok, machine} = Machines.approve_pairing(user, code)
211
    machine
212
  end
213
214
  # Connects a fake controller for the machine and starts the delegation in a
215
  # task; the script hands the request correlation back so the test drives the
216
  # stream itself.
217
  defp start_delegation(machine, agent_id) do
218
    test_pid = self()
219
220
    start_supervised!(
221
      {FakeController,
222
       machine_id: machine.id,
223
       script: fn {:agent, request_id, _payload, caller_pid} ->
224
         send(test_pid, {:delegation_request, machine.id, request_id, caller_pid})
225
       end},
226
      id: {FakeController, machine.id}
227
    )
228
229
    task =
230
      Task.async(fn ->
231
        Computer.request_agent(machine.id, %{"agent_id" => agent_id, "prompt" => "work"}, 5_000)
232
      end)
233
234
    machine_id = machine.id
235
    assert_receive {:delegation_request, ^machine_id, request_id, caller_pid}
236
    %{task: task, request_id: request_id, pid: caller_pid}
237
  end
238
239
  defp eventually(assertion, timeout \\ 1_000) do
240
    deadline = System.monotonic_time(:millisecond) + timeout
241
    do_eventually(assertion, deadline)
242
  end
243
244
  defp do_eventually(assertion, deadline) do
245
    if assertion.() do
246
      true
247
    else
248
      if System.monotonic_time(:millisecond) >= deadline do
249
        false
250
      else
251
        receive do
252
          _message -> :ok
253
        after
254
          10 -> :ok
255
        end
256
257
        do_eventually(assertion, deadline)
258
      end
259
    end
260
  end
261
end
test/openagents_web/live/chat_live_test.exs modified +5 -1

@@ -102,7 +102,11 @@ defmodule OpenAgentsWeb.ChatLiveTest do

102 102
             "#chat-actions-menu a#export-atif[href='/data/export/atif'][download]"
103 103
           )
104 104
105
    assert has_element?(view, "#sidebar #sidebar-sections")
105
    # Chat's own sections region held one thing, the work projection, and went
106
    # with it when `/chat` was zero-based. What chat still contributes to the
107
    # sidebar is the conversation's data action, covered by the reset test
108
    # below.
109
    refute has_element?(view, "#sidebar #sidebar-sections")
106 110
107 111
    # Identity is the command bar's, once, rather than a second account
108 112
    # control in a second footer.
test/openagents_web/live/chat_sidebar_sections_test.exs deleted -144

@@ -1,144 +0,0 @@

1
defmodule OpenAgentsWeb.ChatSidebarSectionsTest do
2
  use OpenAgentsWeb.ConnCase
3
  import Phoenix.LiveViewTest
4
5
  alias OpenAgents.{Conversations, Work}
6
7
  test "the work section is hidden while there is no background work", %{conn: conn} do
8
    conn = log_in_github_user(conn, "sidebar-sections-empty-browser")
9
    {:ok, view, _html} = live(conn, ~p"/sarah")
10
11
    refute has_element?(view, "#sidebar-work")
12
13
    # The calls section was removed; nothing renders it.
14
    refute has_element?(view, "#sidebar-calls")
15
  end
16
17
  test "a seeded job renders a two-line status row targeting its durable report", %{conn: conn} do
18
    user = github_user("sidebar-sections-seeded-browser")
19
    conn = log_in_github_user(conn, "sidebar-sections-seeded-browser")
20
21
    {:ok, conversation} = Conversations.ensure_conversation(user)
22
    owner = Conversations.get_conversation_owner!(conversation)
23
24
    {:ok, job} =
25
      Work.create_job(%{
26
        conversation_id: conversation.id,
27
        owner_visitor_id: owner.id,
28
        surface: "text",
29
        goal: "Collect the release notes"
30
      })
31
32
    {:ok, running} = Work.mark_job_running(job, %{})
33
    {:ok, finished} = Work.finish_job(running.id, "completed")
34
35
    {:ok, view, _html} = live(conn, ~p"/sarah")
36
37
    assert has_element?(view, ~s(#sidebar-job-#{job.id}[data-status="completed"]))
38
    assert has_element?(view, "#sidebar-job-#{job.id} .sidebar-row__title", "Collect the release")
39
    assert has_element?(view, "#sidebar-job-#{job.id} .sidebar-row__meta", "Completed")
40
41
    assert has_element?(
42
             view,
43
             ~s(#sidebar-job-#{job.id} .status-indicator[data-state="succeeded"])
44
           )
45
46
    assert has_element?(
47
             view,
48
             ~s(#sidebar-job-#{job.id} a.sidebar-row__hit[href="#messages-#{finished.report_message_id}"])
49
           )
50
  end
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"/sarah")
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
81
  test "job lifecycle broadcasts refresh the work section without polling", %{conn: conn} do
82
    user = github_user("sidebar-sections-job-broadcast-browser")
83
    conn = log_in_github_user(conn, "sidebar-sections-job-broadcast-browser")
84
85
    {:ok, view, _html} = live(conn, ~p"/sarah")
86
    refute has_element?(view, "#sidebar-work")
87
88
    {:ok, conversation} = Conversations.ensure_conversation(user)
89
    owner = Conversations.get_conversation_owner!(conversation)
90
91
    {:ok, job} =
92
      Work.create_job(%{
93
        conversation_id: conversation.id,
94
        owner_visitor_id: owner.id,
95
        surface: "text",
96
        goal: "Summarize the meeting notes"
97
      })
98
99
    assert eventually(fn ->
100
             has_element?(
101
               view,
102
               ~s(#sidebar-job-#{job.id} .status-indicator[data-state="running"])
103
             )
104
           end)
105
106
    # No durable report yet: honest non-link row.
107
    refute has_element?(view, "#sidebar-job-#{job.id} .sidebar-row__hit")
108
109
    {:ok, running} = Work.mark_job_running(job, %{})
110
    {:ok, finished} = Work.finish_job(running.id, "completed")
111
112
    assert eventually(fn ->
113
             has_element?(
114
               view,
115
               ~s(#sidebar-job-#{job.id} a.sidebar-row__hit[href="#messages-#{finished.report_message_id}"])
116
             )
117
           end)
118
119
    assert has_element?(view, "#sidebar-job-#{job.id} .sidebar-row__meta", "Completed")
120
  end
121
122
  defp eventually(assertion, timeout \\ 1_000) do
123
    deadline = System.monotonic_time(:millisecond) + timeout
124
    do_eventually(assertion, deadline)
125
  end
126
127
  defp do_eventually(assertion, deadline) do
128
    if assertion.() do
129
      true
130
    else
131
      if System.monotonic_time(:millisecond) >= deadline do
132
        false
133
      else
134
        receive do
135
          _message -> :ok
136
        after
137
          10 -> :ok
138
        end
139
140
        do_eventually(assertion, deadline)
141
      end
142
    end
143
  end
144
end
test/openagents_web/live/chat_work_surfaces_absent_test.exs added +201

@@ -0,0 +1,201 @@

1
defmodule OpenAgentsWeb.ChatWorkSurfacesAbsentTest do
2
  @moduledoc """
3
  Pins the zero-based `/chat` decision.
4
5
  The work rail, the sidebar work section, the live delegation panel, and the
6
  deep-work rollup header were removed from `OpenAgentsWeb.ChatLive`. The work
7
  and delegation records they projected are still durable, and the projections
8
  behind them still have their own proofs — `OpenAgents.WorkJobTest` for job
9
  lifecycle broadcasts, `OpenAgents.ComputerActivityTest` for the bounded
10
  delegation stream and its owner scoping. What these tests hold is the
11
  surface: a re-introduced rail, section, panel, or rollup fails here.
12
  """
13
14
  use OpenAgentsWeb.ConnCase
15
  import Phoenix.LiveViewTest
16
17
  alias OpenAgents.{Computer, Conversations, Machines, Work}
18
  alias OpenAgents.Support.FakeController
19
20
  test "no work rail, no sidebar work section, and no rows for a job that ran", %{conn: conn} do
21
    user = github_user("chat-zero-base-work")
22
    conn = log_in_github_user(conn, "chat-zero-base-work")
23
24
    {:ok, conversation} = Conversations.ensure_conversation(user)
25
    owner = Conversations.get_conversation_owner!(conversation)
26
27
    {:ok, job} =
28
      Work.create_job(%{
29
        conversation_id: conversation.id,
30
        owner_visitor_id: owner.id,
31
        surface: "text",
32
        goal: "Rebuild the staging index"
33
      })
34
35
    {:ok, running} = Work.mark_job_running(job, %{})
36
    {:ok, finished} = Work.finish_job(running.id, "completed")
37
38
    # The job is durable and its report landed in the transcript. Nothing below
39
    # is about the job going missing; it is about the surface staying quiet.
40
    assert finished.status == "completed"
41
    assert [_job] = Work.recent_jobs(conversation, 8)
42
43
    {:ok, view, _html} = live(conn, ~p"/sarah")
44
45
    refute has_element?(view, "#chat-rail")
46
    refute has_element?(view, "#chat-rail-toggle")
47
    refute has_element?(view, "#rail-work")
48
    refute has_element?(view, "#sidebar-work")
49
    refute has_element?(view, "#sidebar-job-#{job.id}")
50
    refute has_element?(view, "#rail-job-#{job.id}")
51
52
    # The chat column is the whole shell now: no second column beside it.
53
    assert has_element?(view, ".chat-shell > .app-main")
54
    refute has_element?(view, ".chat-shell > aside")
55
  end
56
57
  test "a deep-work report is an ordinary transcript message, not a rollup header",
58
       %{conn: conn} do
59
    user = github_user("chat-zero-base-rollup")
60
    conn = log_in_github_user(conn, "chat-zero-base-rollup")
61
62
    {:ok, conversation} = Conversations.ensure_conversation(user)
63
    owner = Conversations.get_conversation_owner!(conversation)
64
65
    {:ok, job} =
66
      Work.create_job(%{
67
        conversation_id: conversation.id,
68
        owner_visitor_id: owner.id,
69
        surface: "text",
70
        goal: "Collect the release notes"
71
      })
72
73
    {:ok, running} = Work.mark_job_running(job, %{})
74
    {:ok, finished} = Work.finish_job(running.id, "completed")
75
76
    {:ok, view, _html} = live(conn, ~p"/sarah")
77
78
    # The report is in the transcript and still marks itself as one.
79
    assert has_element?(view, "#messages-#{finished.report_message_id}.message-row--report")
80
    refute has_element?(view, "#job-rollup-messages-#{finished.report_message_id}")
81
    refute has_element?(view, ".job-rollup")
82
  end
83
84
  test "a streamed delegation renders nothing and pushes no chunk to the client",
85
       %{conn: conn} do
86
    %{conn: conn, machine: machine} = delegation_owner(conn, "chat-zero-base-live", "quiet-box")
87
    {:ok, view, _html} = live(conn, ~p"/sarah")
88
89
    caller = start_delegation(machine, "claude")
90
    FakeController.chunk(caller.pid, caller.request_id, "hello from the machine")
91
92
    # `:sys.get_state/1` flushes every message the LiveView had already been
93
    # sent, so this is a settled view rather than a race with the broadcast.
94
    _state = :sys.get_state(view.pid)
95
96
    refute has_element?(view, "#delegation-rail")
97
    refute has_element?(view, "#delegation-inline")
98
    refute has_element?(view, "#delegation-live")
99
    refute has_element?(view, "#cancel-delegation")
100
    refute has_element?(view, ".delegation-summary")
101
    refute render(view) =~ "hello from the machine"
102
    refute_push_event(view, "delegation:chunk", %{})
103
104
    FakeController.exit(caller.pid, caller.request_id, %{
105
      "status" => "completed",
106
      "stop_reason" => "end_turn",
107
      "truncated" => false,
108
      "duration_ms" => 12_000
109
    })
110
111
    assert {:ok, _result} = Task.await(caller.task)
112
113
    _settled = :sys.get_state(view.pid)
114
    refute has_element?(view, "#delegation-terminal")
115
    refute render(view) =~ "end_turn"
116
  end
117
118
  test "another account's conversation never renders the delegation", %{conn: conn} do
119
    %{conn: owner_conn, machine: machine} =
120
      delegation_owner(conn, "chat-zero-base-owner", "owned-box")
121
122
    other_conn = log_in_github_user(build_conn(), "chat-zero-base-outsider")
123
    {:ok, owner_view, _owner_html} = live(owner_conn, ~p"/sarah")
124
    {:ok, other_view, _other_html} = live(other_conn, ~p"/sarah")
125
126
    caller = start_delegation(machine, "claude")
127
    FakeController.chunk(caller.pid, caller.request_id, "owner-only progress")
128
129
    _owner_state = :sys.get_state(owner_view.pid)
130
    _other_state = :sys.get_state(other_view.pid)
131
132
    # The projection's topic is keyed by the owner's conversation, so the other
133
    # account's LiveView structurally never receives the stream
134
    # (`OpenAgents.ComputerActivityTest` proves the scoping itself). The chat
135
    # surface states no delegation for anyone, which is what is held here: the
136
    # streamed text reaches neither transcript.
137
    refute render(owner_view) =~ "owner-only progress"
138
    refute render(other_view) =~ "owner-only progress"
139
    refute has_element?(other_view, "#delegation-rail")
140
    refute has_element?(other_view, "#delegation-inline")
141
    refute has_element?(other_view, "#delegation-live")
142
    refute_push_event(other_view, "delegation:chunk", %{})
143
144
    FakeController.exit(caller.pid, caller.request_id, %{
145
      "status" => "completed",
146
      "stop_reason" => "end_turn",
147
      "truncated" => false,
148
      "duration_ms" => 1
149
    })
150
151
    assert {:ok, _result} = Task.await(caller.task)
152
  end
153
154
  # Logs the account in, ensures its one conversation exists, and pairs one
155
  # machine so a delegation can target it.
156
  defp delegation_owner(conn, key, machine_name) do
157
    user = github_user(key)
158
    conn = log_in_github_user(conn, key)
159
    {:ok, _conversation} = Conversations.ensure_conversation(user)
160
    %{conn: conn, user: user, machine: paired_machine(user, machine_name)}
161
  end
162
163
  defp paired_machine(user, name) do
164
    {:ok, %{code: code}} =
165
      Machines.start_pairing(%{
166
        "name" => name,
167
        "tier" => "curated",
168
        "platform" => "linux-x64",
169
        "agent_version" => "0.1.0",
170
        "roots" => []
171
      })
172
173
    {:ok, machine} = Machines.approve_pairing(user, code)
174
    machine
175
  end
176
177
  # Connects a fake controller for the machine and starts the delegation in a
178
  # task; the script hands the request correlation back so the test drives the
179
  # stream itself.
180
  defp start_delegation(machine, agent_id) do
181
    test_pid = self()
182
183
    start_supervised!(
184
      {FakeController,
185
       machine_id: machine.id,
186
       script: fn {:agent, request_id, _payload, caller_pid} ->
187
         send(test_pid, {:delegation_request, machine.id, request_id, caller_pid})
188
       end},
189
      id: {FakeController, machine.id}
190
    )
191
192
    task =
193
      Task.async(fn ->
194
        Computer.request_agent(machine.id, %{"agent_id" => agent_id, "prompt" => "work"}, 5_000)
195
      end)
196
197
    machine_id = machine.id
198
    assert_receive {:delegation_request, ^machine_id, request_id, caller_pid}
199
    %{task: task, request_id: request_id, pid: caller_pid}
200
  end
201
end

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