Build the chat surface out of AI Elements

087167a907f2 · AtlantisPleb · · parent 3c40cf64b921

Build the chat surface out of AI Elements

The transcript, the composer, the queue, and the tool activity were four
hand-rolled shapes held up by a page of chat-only CSS. The components ported
from AI Elements already describe all four, and better: the tool block states
which of seven states a call is actually in, where a flat activity line only
had a colour and a sentence.

So the surface is composed rather than drawn. The transcript is `conversation`
holding `conversation_content` holding `message`, `message_content`, and
`message_actions`. The composer is `prompt_input` with its textarea, header,
toolbar, and submit — and the submit now states its status, so the control says
a turn is in flight instead of leaving that to the separate stop button beside
it. Queued messages are `queue`. Each durable tool step is `tool`, whose native
`<details>` replaces the button-and-`aria-expanded` disclosure with something
the browser already knows how to operate.

Two ids changed with it, and both tests moved in the same commit. The user
bubble is `message_content`'s `group-[.is-user]:` rules keyed off the marker
`message/1` writes, so `.message-bubble` is gone and the test asserts the
marker. The tool step's disclosure is a `<summary>`, so the test asserts that
rather than a button.

The chat CSS the port makes dead is deleted rather than left to fight the
utilities that replaced it — `app.css` is unlayered, so a surviving
`background` there would have silently repainted the ported bubble. Ninety-odd
lines go from `app.css` and sixty from `openagents.css`; what stays is what no
utility says, mostly the reading measure the transcript and the composer share
and the pre-wrap that preserves the line breaks a person typed.

The shimmer gets its sweep back. Batch A could not touch CSS, so it held the
gradient at the centre and borrowed `animate-pulse`, and the band breathed
instead of travelling. The `shimmer-sweep` keyframes move it; the stand-in had
to go rather than be overridden, because a second `animation` shorthand simply
replaces the first.

Behaviour is unchanged. Sending, queueing, cancel, voice, the delegation rail,
work rows, copy and copy-link, timestamps, the composer error, and the operator
gating all work as before. The two colocated hooks shed what the AI Elements
hooks now own and keep only what they do not: `.TranscriptScroll` keeps the
copied-link anchor, the prepend position, and the server's scroll event, and
lands the anchor a frame late so the conversation's own pin does not scroll
past it; `.Composer` keeps focus and clear.

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/app.css
  • modified assets/css/openagents.css
  • modified lib/openagents_web/components/ai/conversation.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified test/openagents_web/components/ai/conversation_test.exs
  • modified test/openagents_web/live/chat_live_test.exs

Diff

6 files changed, +672 -557

assets/css/app.css modified +15 -116

@@ -623,128 +623,27 @@ body.sidebar-open {

623 623
624 624
/* Chat UI (OpenAgents) */
625 625
626
.chat-transcript {
627
  display: flex;
628
  flex: 1;
629
  flex-direction: column;
630
  gap: 24px;
631
  overflow-y: auto;
632
  overscroll-behavior: none;
633
}
634
635
.message-row {
636
  display: flex;
637
  max-width: 720px;
638
  flex-direction: column;
639
  gap: 6px;
640
}
641
642
.message-row--assistant {
643
  align-self: flex-start;
644
}
645
646
.message-row--user {
647
  align-self: flex-end;
648
  align-items: flex-end;
649
}
650
651
.message-body {
652
  display: flex;
653
  flex-direction: column;
654
  gap: 4px;
655
}
656
657
.message-time {
658
  color: var(--text-muted);
659
  font-size: 0.75rem;
660
  line-height: 1rem;
661
}
626
/* The transcript and the composer are AI Elements now — `conversation`,
627
 * `message`, `message_content`, and `prompt_input` carry the column, the
628
 * asymmetry, the bubble, and the input group that used to live here as
629
 * `.chat-transcript`, `.message-row`, `.message-body`, `.message-bubble`,
630
 * `.composer`, `.composer-card`, `.composer-grid`, `.composer-input`, and
631
 * `.composer-trailing`. Those rules are gone rather than left to outrank the
632
 * utilities that replaced them: everything in this file is unlayered, so it
633
 * beats every Tailwind layer, and a leftover `background` here would have
634
 * silently repainted the ported bubble.
635
 *
636
 * One declaration survives, because no utility states it: the line breaks a
637
 * person actually typed. */
662 638
663 639
.message-content {
664
  color: var(--text-primary);
665
  font-size: 0.9375rem;
666
  line-height: 1.5rem;
667 640
  white-space: pre-wrap;
668 641
  word-break: break-word;
669 642
}
670 643
671
.message-bubble {
672
  max-width: 560px;
673
  padding: 10px 14px;
674
  border-radius: 16px;
675
  border-bottom-right-radius: 4px;
676
  background: var(--accent);
677
  color: var(--text-primary);
678
}
679
680
.composer {
681
  padding: 12px 16px;
682
  border-top: 1px solid var(--ink-raised);
683
  background: var(--ink-void);
684
}
685
686
.composer-card {
687
  position: relative;
688
  max-width: 720px;
689
  margin: 0 auto;
690
  padding: 6px;
691
  border: 1px solid var(--ink-raised);
692
  border-radius: 24px;
693
  background: var(--ink-surface);
694
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
695
}
696
697
.composer-card:focus-within {
698
  border-color: var(--text-dim);
699
}
700
701
.composer-grid {
702
  display: grid;
703
  align-items: end;
704
  grid-template-columns: 1fr auto;
705
  gap: 8px;
706
  min-height: 44px;
707
}
708
709
.composer-input {
710
  resize: none;
711
  min-height: 36px;
712
  max-height: 200px;
713
  padding: 10px 12px;
714
  border: 0;
715
  border-radius: 18px;
716
  background: transparent;
717
  color: var(--text-primary);
718
  font-family: inherit;
719
  font-size: 0.9375rem;
720
  line-height: 1.5rem;
721
  outline: none;
722
}
723
724
.composer-input::placeholder {
725
  color: var(--text-muted);
726
}
727
728
/* The card is the control, so the card carries the focus state. Basecoat's
729
   `.textarea:focus` paints a 3px ring, and the composer autofocuses on mount,
730
   so that ring drew a second rounded rectangle inside the card from the moment
731
   the page loaded. `.composer-card:focus-within` already states the same
732
   thing, once, on the right element. */
733
.composer-input:focus,
734
.composer-input:focus-visible {
735
  border: 0;
736
  box-shadow: none;
737
  outline: none;
738
}
739
740
.composer-trailing {
741
  display: flex;
742
  align-items: center;
743
  gap: 6px;
744
  padding-inline-end: 4px;
745
  padding-block-end: 4px;
746
}
747
644
/* The chat composer's send control is `prompt_input_submit/1` now. What is
645
 * left on this class is the voice spike's start control, which is not part of
646
 * the product surface and has no primitive of its own. */
748 647
.send-action {
749 648
  display: inline-flex;
750 649
  width: 36px;
assets/css/openagents.css modified +73 -71

@@ -2692,27 +2692,24 @@

2692 2692
2693 2693
  /* ── Transcript ─────────────────────────────────────────────────────────── */
2694 2694
2695
  /* Not a scroller any more: `conversation/1` is, and it clips at its own
2696
     box so the return-to-newest control can hold still against the bottom
2697
     edge. What is left here is the column the conversation fills. */
2695 2698
  .transcript {
2696 2699
    display: flex;
2697 2700
    min-height: 0;
2698 2701
    flex: 1;
2699 2702
    flex-direction: column;
2700
    gap: 24px;
2701
    overflow-y: auto;
2702
    overscroll-behavior: contain;
2703
    padding: 8px 24px 24px;
2704 2703
  }
2705 2704
2705
  /* A reading column, centred, so a wide window does not stretch prose to the
2706
     full width of a desktop display. The composer below uses the same measure,
2707
     so the two share one axis. The display, direction, gap, and padding are
2708
     `conversation_content/1`'s. */
2706 2709
  .message-list {
2707
    display: flex;
2708 2710
    width: 100%;
2709 2711
    max-width: 46rem;
2710
    flex-direction: column;
2711
    /* A reading column, centred, so a wide window does not stretch prose to
2712
       the full width of a desktop display. The composer below uses the same
2713
       measure, so the two share one axis. */
2714 2712
    margin-inline: auto;
2715
    gap: 20px;
2716 2713
  }
2717 2714
2718 2715
  .history-control {

@@ -2721,9 +2718,9 @@

2721 2718
    padding-block: 4px;
2722 2719
  }
2723 2720
2721
  /* Colour and size come from `message_content/1`'s own utilities, which
2722
     outrank this layer; what is left is the prose rhythm inside a turn. */
2724 2723
  .message-markdown {
2725
    color: var(--text-body);
2726
    font-size: 0.9375rem;
2727 2724
    line-height: 1.65;
2728 2725
    word-break: break-word;
2729 2726
  }

@@ -2790,10 +2787,8 @@

2790 2787
2791 2788
  /* ── Message chrome ─────────────────────────────────────────────────────── */
2792 2789
2790
  /* `message_actions/1` supplies the row; this supplies its voice. */
2793 2791
  .message-toolbar {
2794
    display: flex;
2795
    align-items: center;
2796
    gap: 4px;
2797 2792
    color: var(--text-dim);
2798 2793
  }
2799 2794

@@ -2822,34 +2817,13 @@

2822 2817
    line-height: 1rem;
2823 2818
  }
2824 2819
2820
  /* `queue/1` and its parts draw the queue itself. All this states is that
2821
     the queue shares the composer's measure and sits above it. */
2825 2822
  .message-queue {
2826
    display: flex;
2827
    flex-direction: column;
2828
    gap: 6px;
2829
  }
2830
2831
  .message-queue__item {
2832
    display: flex;
2833
    align-items: center;
2834
    gap: 8px;
2835
    border: 1px dashed var(--line);
2836
    border-radius: var(--radius-lg);
2837
    padding: 8px 10px;
2838
  }
2839
2840
  .message-queue__label {
2841
    color: var(--text-dim);
2842
    font-size: 0.6875rem;
2843
    letter-spacing: 0.06em;
2844
    text-transform: uppercase;
2845
  }
2846
2847
  .message-queue__text {
2848
    min-width: 0;
2849
    flex: 1;
2850
    color: var(--text-muted);
2851
    font-size: 0.875rem;
2852
    overflow-wrap: anywhere;
2823
    width: 100%;
2824
    max-width: 46rem;
2825
    margin-inline: auto;
2826
    margin-block-end: 8px;
2853 2827
  }
2854 2828
2855 2829
  /* ── Composer ───────────────────────────────────────────────────────────── */

@@ -2863,14 +2837,19 @@

2863 2837
    padding: 0 24px 20px;
2864 2838
  }
2865 2839
2866
  .composer-eyebrow {
2867
    display: flex;
2840
  /* `prompt_input/1`'s form is `w-full`, so the reading measure the composer
2841
     shares with the transcript is stated here rather than by the component. */
2842
  .composer {
2843
    width: 100%;
2868 2844
    max-width: 46rem;
2869
    align-items: center;
2870
    justify-content: space-between;
2871
    gap: 8px;
2872 2845
    margin-inline: auto;
2873
    padding-block-end: 8px;
2846
  }
2847
2848
  /* Inside the input group's leading strip now, so it no longer carries a
2849
     measure or a margin of its own — only its voice, and the full width of
2850
     the strip it fills. */
2851
  .composer-eyebrow {
2852
    width: 100%;
2874 2853
    color: var(--text-dim);
2875 2854
    font-size: 0.75rem;
2876 2855
  }

@@ -2923,13 +2902,12 @@

2923 2902
2924 2903
  /* ── Activity ───────────────────────────────────────────────────────────── */
2925 2904
2905
  /* Each step is a `tool/1` block, which carries its own bottom margin, so
2906
     this states the measure and nothing about the spacing between them. */
2926 2907
  .tool-activity {
2927
    display: flex;
2928 2908
    width: 100%;
2929 2909
    max-width: 46rem;
2930
    flex-direction: column;
2931 2910
    margin-inline: auto;
2932
    gap: 4px;
2933 2911
  }
2934 2912
2935 2913
  .job-rollup {

@@ -3455,42 +3433,66 @@

3455 3433
    opacity: 0.6;
3456 3434
  }
3457 3435
3458
  .delegation-summary__dismiss,
3459
  .message-queue__dismiss {
3436
  .delegation-summary__dismiss {
3460 3437
    flex: none;
3461 3438
    margin-inline-start: auto;
3462 3439
  }
3463 3440
}
3464 3441
3465
/* ── Composer action states ───────────────────────────────────────────────── */
3442
/* ── Composer and transcript tails ────────────────────────────────────────── */
3466 3443
3467
/* One control, three jobs: send, stop a running turn, and end a voice session.
3468
 * Each states its job in shape and colour rather than only in its glyph. */
3444
/* Send, stop, and end-a-call are `prompt_input_submit/1` and
3445
 * `prompt_input_button/1` now, and each states its job through the button
3446
 * variant it is given. What is left here is the two boxes those controls sit
3447
 * in. */
3469 3448
3470 3449
@layer components {
3471
  .send-action--stop {
3472
    background: var(--danger);
3473
    color: var(--control-inverse-fg);
3450
  .voice-controller--in-composer {
3451
    width: 100%;
3474 3452
  }
3475 3453
3476
  .send-action--voice,
3477
  .send-action--voice-end {
3478
    background: transparent;
3479
    border: 1px solid var(--line-strong);
3480
    color: var(--icon-primary);
3454
  .tool-activity--live {
3455
    padding-block-start: 4px;
3481 3456
  }
3457
}
3482 3458
3483
  .send-action--voice-end {
3484
    border-color: var(--danger);
3485
    color: var(--danger);
3459
/* ── Shimmer ──────────────────────────────────────────────────────────────── */
3460
3461
/* The sweep behind `OpenAgentsWeb.AI.Conversation.shimmer/1`. AI Elements
3462
 * animates `background-position` with `motion/react` across a gradient 250% as
3463
 * wide as the box, clipped to the glyphs: a bright band travels through muted
3464
 * text and the text reads as still arriving. The component carries the whole
3465
 * gradient and marks itself `data-shimmer`; the movement is this rule.
3466
 *
3467
 * The declaration it overrides — `[background-position:50%_center]` — is a
3468
 * Tailwind utility, and utilities outrank `@layer components`. An animation
3469
 * wins anyway: animated values sit above all normal declarations in the
3470
 * cascade whatever layer they came from. That is also why the component had to
3471
 * drop `animate-pulse` rather than have it overridden — two animations on one
3472
 * element, and the later `animation` shorthand simply replaces the earlier. */
3473
3474
@keyframes shimmer-sweep {
3475
  from {
3476
    background-position: 100% center;
3486 3477
  }
3487 3478
3488
  .voice-controller--in-composer {
3489
    width: 100%;
3479
  to {
3480
    background-position: 0% center;
3490 3481
  }
3482
}
3491 3483
3492
  .tool-activity--live {
3493
    padding-block-start: 4px;
3484
@layer components {
3485
  [data-shimmer="true"] {
3486
    animation: shimmer-sweep 2s linear infinite;
3487
  }
3488
3489
  /* Stated twice on purpose: the component also carries
3490
     `motion-reduce:animate-none`, and whichever of the two lands first, the
3491
     band holds still. */
3492
  @media (prefers-reduced-motion: reduce) {
3493
    [data-shimmer="true"] {
3494
      animation: none;
3495
    }
3494 3496
  }
3495 3497
}
3496 3498
lib/openagents_web/components/ai/conversation.ex modified +9 -6

@@ -419,11 +419,14 @@ defmodule OpenAgentsWeb.AI.Conversation do

419 419
  Text that reads as still arriving.
420 420
421 421
  The source animates `background-position` across a 250%-wide gradient with
422
  `motion/react`, which needs a `@keyframes` rule this port may not add. The
423
  gradient machinery is kept exactly — clipped to the glyphs, muted base, bright
424
  band — and held at the centre, and the life comes from Tailwind's own
425
  `animate-pulse`. The band sweeps in AI Elements and breathes here; restoring
426
  the sweep is one `@keyframes` rule in the stylesheet.
422
  `motion/react`. The gradient machinery is kept exactly — clipped to the
423
  glyphs, muted base, bright band — and the sweep comes from the `shimmer-sweep`
424
  keyframes in `assets/css/openagents.css`, which the `data-shimmer` marker
425
  below selects. An animation outranks the `[background-position:50%_center]`
426
  utility that sets the resting position, so the band moves rather than sitting
427
  still. `motion-reduce:animate-none` stops it, and the stylesheet states the
428
  same guard, so a reader who asked for less motion gets a still band from
429
  whichever rule lands first.
427 430
428 431
  The highlight also moves from `--color-background` to `--color-foreground`.
429 432
  AI Elements is a light-first surface where the page ground is the brightest

@@ -452,7 +455,7 @@ defmodule OpenAgentsWeb.AI.Conversation do

452 455
        "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-foreground),#0000_calc(50%+var(--spread)))]",
453 456
        "[background-repeat:no-repeat,padding-box] [background-position:50%_center]",
454 457
        "[background-image:var(--bg),linear-gradient(var(--color-muted-foreground),var(--color-muted-foreground))]",
455
        "animate-pulse motion-reduce:animate-none",
458
        "motion-reduce:animate-none",
456 459
        @class
457 460
      ]}
458 461
      style={"--spread: #{@dynamic_spread}px"}
lib/openagents_web/live/chat_live.ex modified +421 -353

@@ -19,6 +19,45 @@ defmodule OpenAgentsWeb.ChatLive do

19 19
  alias OpenAgentsWeb.ToolActivity
20 20
  alias OpenAgentsWeb.UI
21 21
22
  # The chat surface is composed from the ported AI Elements, not from bespoke
23
  # chat CSS: the transcript is `conversation`/`message`, the composer is
24
  # `prompt_input`, queued messages are `queue`, and a tool call is `tool`.
25
  # Imported rather than aliased so the call sites still read as AI Elements.
26
  import OpenAgentsWeb.AI.Conversation,
27
    only: [
28
      conversation: 1,
29
      conversation_content: 1,
30
      message: 1,
31
      message_content: 1,
32
      message_actions: 1,
33
      message_action: 1,
34
      shimmer: 1
35
    ]
36
37
  import OpenAgentsWeb.AI.PromptInput,
38
    only: [
39
      prompt_input: 1,
40
      prompt_input_textarea: 1,
41
      prompt_input_header: 1,
42
      prompt_input_toolbar: 1,
43
      prompt_input_tools: 1,
44
      prompt_input_button: 1,
45
      prompt_input_submit: 1,
46
      queue: 1,
47
      queue_section: 1,
48
      queue_section_trigger: 1,
49
      queue_section_label: 1,
50
      queue_section_content: 1,
51
      queue_list: 1,
52
      queue_item: 1,
53
      queue_item_indicator: 1,
54
      queue_item_content: 1,
55
      queue_item_actions: 1,
56
      queue_item_action: 1
57
    ]
58
59
  import OpenAgentsWeb.AI.Reasoning, only: [tool: 1, tool_header: 1, tool_content: 1]
60
22 61
  # The sidebar's calls and work sections are bounded projections, not
23 62
  # unbounded lists: the last eight of each, recomputed on the same PubSub
24 63
  # broadcasts that drive the transcript.

@@ -706,76 +745,82 @@ defmodule OpenAgentsWeb.ChatLive do

706 745
707 746
      <div id="openagents-app" class="chat-shell">
708 747
        <main class="app-main">
709
          <section
710
            id="transcript"
711
            class="transcript"
712
            aria-label="Conversation transcript"
713
            phx-hook=".TranscriptScroll"
714
          >
715
            <div :if={@has_older?} class="history-control">
716
              <.text_button id="load-older" phx-click="load_older">
717
                <.icon name="history" /> LOAD EARLIER MESSAGES
718
              </.text_button>
719
            </div>
720
721
            <%!-- Cloned into each rendered code block by the TranscriptActions
722
                  hook, so the copy affordance ships from the template (vendored
723
                  glyph, accessible name) rather than being built in script. --%>
724
            <template id="code-copy-template">
725
              <.button
726
                variant={:ghost}
727
                size={:xs}
728
                class="code-copy"
729
                aria-label="Copy code"
730
                data-copy-kind="code"
731
              >
732
                <.icon name="copy" />
733
              </.button>
734
            </template>
735
736
            <div
737
              id="messages"
738
              class="message-list"
739
              phx-update="stream"
740
              aria-live="polite"
741
              phx-hook=".TranscriptActions"
742
            >
743
              <.message_row
744
                :for={{dom_id, message} <- @streams.messages}
745
                id={dom_id}
746
                message={message}
747
                paced_items={@paced_voice_items}
748
                activity={Map.get(@message_activity, message.id, [])}
749
                rollup={Map.get(@job_rollups, message.id)}
750
              />
751
            </div>
752
753
            <%!-- Voice tool activity has no assistant message to attach to until a
754
                  transcript lands, so a live session's steps render at the tail of
755
                  the transcript. Text turns attach activity to their assistant
756
                  message row instead, so this never duplicates them. --%>
757
            <section
758
              :if={@active_turn == nil and @tool_activity != []}
759
              id="live-tool-activity"
760
              class="tool-activity tool-activity--live"
761
              role="status"
762
              aria-live="polite"
763
              aria-atomic="false"
764
              aria-label="Sarah activity"
765
            >
766
              <.activity_event
767
                :for={activity <- @tool_activity}
768
                id={"live-tool-activity-step-#{activity.id}"}
769
                activity={activity}
770
              />
771
            </section>
772
773
            <%!-- Below the desktop breakpoint the live delegation projection
774
                  renders inline at the transcript tail on the event-header
775
                  expansion pattern, instead of as a rail. One projection,
776
                  two placements; the stylesheet shows exactly one. --%>
777
            <.delegation_inline :if={@delegation} delegation={@delegation} />
778
          </section>
748
          <%!-- `conversation/1` owns the scroller and the return-to-newest
749
                control; this wrapper exists only to carry `.TranscriptScroll`,
750
                which owns the three things the AI Elements hook does not: the
751
                copied-link anchor, scroll preservation when older messages are
752
                prepended, and the server's own scroll-to-bottom event. --%>
753
          <div id="transcript" class="transcript" phx-hook=".TranscriptScroll">
754
            <.conversation id="conversation" aria-label="Conversation transcript">
755
              <.conversation_content id="conversation-content" class="message-list">
756
                <div :if={@has_older?} class="history-control">
757
                  <.text_button id="load-older" phx-click="load_older">
758
                    <.icon name="history" /> LOAD EARLIER MESSAGES
759
                  </.text_button>
760
                </div>
761
762
                <%!-- Cloned into each rendered code block by the TranscriptActions
763
                      hook, so the copy affordance ships from the template (vendored
764
                      glyph, accessible name) rather than being built in script. --%>
765
                <template id="code-copy-template">
766
                  <.button
767
                    variant={:ghost}
768
                    size={:xs}
769
                    class="code-copy"
770
                    aria-label="Copy code"
771
                    data-copy-kind="code"
772
                  >
773
                    <.icon name="copy" />
774
                  </.button>
775
                </template>
776
777
                <%!-- `display: contents`, so the stream container the hook and the
778
                      tests need does not become a second box between the turns and
779
                      the column that spaces them. --%>
780
                <div
781
                  id="messages"
782
                  class="contents"
783
                  phx-update="stream"
784
                  phx-hook=".TranscriptActions"
785
                >
786
                  <.message_row
787
                    :for={{dom_id, message} <- @streams.messages}
788
                    id={dom_id}
789
                    message={message}
790
                    paced_items={@paced_voice_items}
791
                    activity={Map.get(@message_activity, message.id, [])}
792
                    rollup={Map.get(@job_rollups, message.id)}
793
                  />
794
                </div>
795
796
                <%!-- Voice tool activity has no assistant message to attach to until a
797
                      transcript lands, so a live session's steps render at the tail of
798
                      the transcript. Text turns attach activity to their assistant
799
                      message row instead, so this never duplicates them. --%>
800
                <section
801
                  :if={@active_turn == nil and @tool_activity != []}
802
                  id="live-tool-activity"
803
                  class="tool-activity tool-activity--live"
804
                  role="status"
805
                  aria-live="polite"
806
                  aria-atomic="false"
807
                  aria-label="Sarah activity"
808
                >
809
                  <.activity_event
810
                    :for={activity <- @tool_activity}
811
                    id={"live-tool-activity-step-#{activity.id}"}
812
                    activity={activity}
813
                  />
814
                </section>
815
816
                <%!-- Below the desktop breakpoint the live delegation projection
817
                      renders inline at the transcript tail on the event-header
818
                      expansion pattern, instead of as a rail. One projection,
819
                      two placements; the stylesheet shows exactly one. --%>
820
                <.delegation_inline :if={@delegation} delegation={@delegation} />
821
              </.conversation_content>
822
            </.conversation>
823
          </div>
779 824
780 825
          <footer class="composer-region">
781 826
            <section

@@ -828,40 +873,52 @@ defmodule OpenAgentsWeb.ChatLive do

828 873
      </div>
829 874
830 875
      <script :type={Phoenix.LiveView.ColocatedHook} name=".TranscriptScroll">
876
        // Follows the AI Elements conversation's own `.StickToBottom`, which
877
        // owns pinning to the newest turn and the scroll-to-newest control.
878
        // What is left here is what that hook does not do: land on a copied
879
        // message link, hold the reader's place when older messages are
880
        // prepended above them, and honour the server's scroll-to-bottom event.
831 881
        export default {
832 882
          mounted() {
833 883
            this.preserveNextUpdate = false
884
            this.viewport = this.el.querySelector("[data-conversation-viewport]")
834 885
            // A copied message link lands here as /chat#<row id>: scroll the
835 886
            // row into view and flash it once. The flash animation is killed
836 887
            // by the global reduced-motion rule, so the mechanic stays
837 888
            // motion-safe.
889
            //
890
            // On the next frame, not now. This element is the conversation's
891
            // parent, so its hook mounts first, and the conversation's own
892
            // mount then pins the viewport to the newest turn — which would
893
            // scroll straight past the row the link named. Landing a frame
894
            // later both wins that race and moves the viewport off the bottom,
895
            // which unpins the other hook so it stays where the reader was
896
            // sent.
838 897
            const anchor = window.location.hash.slice(1)
839 898
            const target = anchor && document.getElementById(anchor)
840 899
            if (target && this.el.contains(target)) {
841
              target.scrollIntoView({ block: "center" })
842
              target.setAttribute("data-flash", "")
843
              setTimeout(() => target.removeAttribute("data-flash"), 1600)
844
            } else {
845
              this.scrollToBottom()
900
              requestAnimationFrame(() => {
901
                target.scrollIntoView({ block: "center" })
902
                target.setAttribute("data-flash", "")
903
                setTimeout(() => target.removeAttribute("data-flash"), 1600)
904
              })
846 905
            }
847 906
            this.handleEvent("chat:scroll-bottom", () => this.scrollToBottom())
848 907
            this.handleEvent("history:prepend", () => { this.preserveNextUpdate = true })
849 908
          },
850 909
          beforeUpdate() {
851
            this.previousHeight = this.el.scrollHeight
852
            this.previousTop = this.el.scrollTop
853
            this.wasNearBottom = this.el.scrollHeight - this.el.scrollTop - this.el.clientHeight < 120
910
            if (!this.viewport) return
911
            this.previousHeight = this.viewport.scrollHeight
912
            this.previousTop = this.viewport.scrollTop
854 913
          },
855 914
          updated() {
856
            if (this.preserveNextUpdate) {
857
              this.el.scrollTop = this.previousTop + (this.el.scrollHeight - this.previousHeight)
858
              this.preserveNextUpdate = false
859
            } else if (this.wasNearBottom) {
860
              this.scrollToBottom()
861
            }
915
            if (!this.viewport || !this.preserveNextUpdate) return
916
            this.viewport.scrollTop =
917
              this.previousTop + (this.viewport.scrollHeight - this.previousHeight)
918
            this.preserveNextUpdate = false
862 919
          },
863 920
          scrollToBottom() {
864
            this.el.scrollTop = this.el.scrollHeight
921
            if (this.viewport) this.viewport.scrollTop = this.viewport.scrollHeight
865 922
          }
866 923
        }
867 924
      </script>

@@ -925,45 +982,21 @@ defmodule OpenAgentsWeb.ChatLive do

925 982
      </script>
926 983
927 984
      <script :type={Phoenix.LiveView.ColocatedHook} name=".Composer">
985
        // The AI Elements `.PromptInput` hook on the form owns auto-resize and
986
        // Enter-to-submit. What is left here is the pair of server events that
987
        // drive the control from the LiveView: focus it when a turn ends, and
988
        // empty it after LiveView has read the submitted form.
928 989
        export default {
929 990
          mounted() {
930
            this.card = this.el.closest(".composer-card")
931
            this.resize()
932
            this.el.addEventListener("input", () => this.resize())
933
            this.el.addEventListener("keydown", event => {
934
              if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
935
                event.preventDefault()
936
                if (!this.el.disabled && this.el.value.trim()) this.el.form.requestSubmit()
937
              }
938
            })
939 991
            this.handleEvent("composer:focus", () => this.el.focus())
940 992
            // LiveView reads FormData during the submit event. Clearing here
941 993
            // races that read and can send an empty message.
942 994
            this.handleEvent("composer:clear", () => this.clear())
943 995
          },
944
          updated() { this.resize() },
945 996
          clear() {
946 997
            this.el.value = ""
947
            this.resize()
948
          },
949
          resize() {
950
            this.el.style.height = "auto"
951
            this.el.style.height = `${Math.min(this.el.scrollHeight, 208)}px`
952
            // One line keeps the card's single collapsed row; a newline or
953
            // wrapped growth flips data-expanded and the grid-template-areas
954
            // swap reorganizes the card. Re-measured on every input and patch,
955
            // so LiveView updates cannot strand a stale attribute.
956
            if (!this.oneLine) {
957
              const style = getComputedStyle(this.el)
958
              this.oneLine =
959
                parseFloat(style.lineHeight) +
960
                parseFloat(style.paddingTop) +
961
                parseFloat(style.paddingBottom)
962
            }
963
            const expanded =
964
              this.el.value.includes("\n") || this.el.scrollHeight > this.oneLine + 2
965
            if (this.card) this.card.toggleAttribute("data-expanded", expanded)
966
            this.el.toggleAttribute("data-overflowing", this.el.scrollHeight > 208)
998
            // Hands the resize back to the form's hook rather than repeating it.
999
            this.el.dispatchEvent(new Event("input", { bubbles: true }))
967 1000
          }
968 1001
        }
969 1002
      </script>

@@ -1576,55 +1609,68 @@ defmodule OpenAgentsWeb.ChatLive do

1576 1609
  attr :id, :string, required: true
1577 1610
  attr :activity, :map, required: true
1578 1611
1579
  # One durable tool step as an event header: the collapsed title says what
1580
  # actually ran, and the expansion carries the bounded durable details —
1581
  # including the executor disclosure, which lives here now rather than on
1582
  # every collapsed row.
1612
  # One durable tool step as an AI Elements tool block: the summary says what
1613
  # actually ran and states the step's real state as a word beside a status
1614
  # badge, and the expansion carries the bounded durable details — including
1615
  # the executor disclosure, which lives here rather than on every collapsed
1616
  # row. `<details>` supplies the disclosure, so no script and no ARIA to keep
1617
  # in sync; a stream re-insert collapses the row again, which is honest.
1583 1618
  defp activity_event(assigns) do
1584 1619
    ~H"""
1585
    <.event_header
1586
      id={@id}
1587
      status={@activity.status}
1588
      title={ToolActivity.title(@activity)}
1589
      title_attribute={ToolActivity.title_attribute(@activity)}
1590
      status_note={ToolActivity.status_note(@activity)}
1591
      timestamp={ToolActivity.timestamp(@activity)}
1592
    >
1593
      <dl class="event-detail">
1594
        <div :if={arguments = ToolActivity.arguments_pretty(@activity)}>
1595
          <dt>ARGUMENTS</dt>
1596
          <dd><pre>{arguments}</pre></dd>
1597
        </div>
1598
        <div :if={result = ToolActivity.payload_pretty(Map.get(@activity, :result))}>
1599
          <dt>RESULT</dt>
1600
          <dd><pre>{result}</pre></dd>
1601
        </div>
1602
        <div :if={error = ToolActivity.payload_pretty(Map.get(@activity, :error))}>
1603
          <dt>ERROR</dt>
1604
          <dd><pre>{error}</pre></dd>
1605
        </div>
1606
        <div :if={detail = ToolActivity.executor_detail(@activity)}>
1607
          <dt>EXECUTOR</dt>
1608
          <dd>
1609
            <span :if={Map.get(@activity, :executor_id)} class="event-detail__executor-id">
1610
              {@activity.executor_id}
1611
            </span>
1612
            {detail}
1613
          </dd>
1614
        </div>
1615
        <div>
1616
          <dt>STATUS</dt>
1617
          <dd>{@activity.status}</dd>
1618
        </div>
1619
        <div :if={timeline = ToolActivity.timeline(@activity)}>
1620
          <dt>TIMELINE</dt>
1621
          <dd>{timeline}</dd>
1622
        </div>
1623
      </dl>
1624
    </.event_header>
1620
    <.tool id={@id}>
1621
      <.tool_header
1622
        title={ToolActivity.title(@activity)}
1623
        type="dynamic-tool"
1624
        tool_name={@activity.tool_name}
1625
        state={tool_state(@activity.status)}
1626
      />
1627
      <.tool_content id={"#{@id}-details"}>
1628
        <dl class="event-detail">
1629
          <div :if={arguments = ToolActivity.arguments_pretty(@activity)}>
1630
            <dt>ARGUMENTS</dt>
1631
            <dd><pre>{arguments}</pre></dd>
1632
          </div>
1633
          <div :if={result = ToolActivity.payload_pretty(Map.get(@activity, :result))}>
1634
            <dt>RESULT</dt>
1635
            <dd><pre>{result}</pre></dd>
1636
          </div>
1637
          <div :if={error = ToolActivity.payload_pretty(Map.get(@activity, :error))}>
1638
            <dt>ERROR</dt>
1639
            <dd><pre>{error}</pre></dd>
1640
          </div>
1641
          <div :if={detail = ToolActivity.executor_detail(@activity)}>
1642
            <dt>EXECUTOR</dt>
1643
            <dd>
1644
              <span :if={Map.get(@activity, :executor_id)} class="event-detail__executor-id">
1645
                {@activity.executor_id}
1646
              </span>
1647
              {detail}
1648
            </dd>
1649
          </div>
1650
          <div>
1651
            <dt>STATUS</dt>
1652
            <dd>{@activity.status}</dd>
1653
          </div>
1654
          <div :if={timeline = ToolActivity.timeline(@activity)}>
1655
            <dt>TIMELINE</dt>
1656
            <dd>{timeline}</dd>
1657
          </div>
1658
        </dl>
1659
      </.tool_content>
1660
    </.tool>
1625 1661
    """
1626 1662
  end
1627 1663
1664
  # The durable step statuses mapped onto the AI SDK tool-part states the
1665
  # ported `tool_header/1` badge reads. Every terminal status that is not a
1666
  # success or a refusal is an error state; the expansion still states the
1667
  # exact word, so nothing is lost by the narrower vocabulary.
1668
  defp tool_state("requested"), do: "input-streaming"
1669
  defp tool_state("running"), do: "input-available"
1670
  defp tool_state("succeeded"), do: "output-available"
1671
  defp tool_state("refused"), do: "output-denied"
1672
  defp tool_state(_failed_cancelled_unavailable_or_interrupted), do: "output-error"
1673
1628 1674
  attr :id, :string, required: true
1629 1675
  attr :message, :map, required: true
1630 1676
  attr :paced_items, :any, required: true

@@ -1633,17 +1679,45 @@ defmodule OpenAgentsWeb.ChatLive do

1633 1679
1634 1680
  # The transcript's asymmetry carries the roles (DESIGN.md, Message row): a
1635 1681
  # person's message is a right-aligned tinted bubble, Sarah's is bare
1636
  # full-measure prose. Role labels and avatars retired with it; provenance
1637
  # that means something — VOICE TRANSCRIPT / INTERRUPTED, the rare SYSTEM
1638
  # row — keeps its label.
1682
  # full-measure prose. Both now come from AI Elements: `message/1` carries the
1683
  # `is-user`/`is-assistant` marker and `message_content/1` reads it for the
1684
  # bubble. Role labels and avatars retired with the asymmetry; provenance that
1685
  # means something — VOICE TRANSCRIPT / INTERRUPTED, the rare SYSTEM row —
1686
  # keeps its label.
1687
  #
1688
  # `items-end` on a person's row is the one thing the components do not
1689
  # supply: `message/1` pushes the row to the right edge and
1690
  # `message_content/1` pushes the bubble inside it, but the toolbar above the
1691
  # bubble is the row's own child and would otherwise stay at the left.
1639 1692
  defp message_row(assigns) do
1693
    # The four content branches are mutually exclusive, so the conditions are
1694
    # derived once here rather than restated on each of them.
1695
    paced? = paced_live_transcript?(assigns.message, assigns.paced_items)
1696
    written? = assigns.message.content != ""
1697
1698
    assigns =
1699
      assigns
1700
      |> assign(:paced?, paced? and written?)
1701
      |> assign(:report?, assigns.rollup != nil and written?)
1702
      |> assign(
1703
        :prose?,
1704
        assigns.rollup == nil and written? and not paced? and markdown?(assigns.message)
1705
      )
1706
      |> assign(
1707
        :plain?,
1708
        assigns.rollup == nil and written? and not paced? and not markdown?(assigns.message)
1709
      )
1710
      |> assign(:streaming?, assigns.message.status == "streaming")
1711
1640 1712
    ~H"""
1641
    <article
1713
    <.message
1642 1714
      id={@id}
1715
      from={@message.role}
1643 1716
      class={[
1644 1717
        "message-row",
1645 1718
        "message-row--#{@message.role}",
1646
        @message.work_job_id && "message-row--report"
1719
        @message.work_job_id && "message-row--report",
1720
        @message.role == "user" && "items-end"
1647 1721
      ]}
1648 1722
      data-status={@message.status}
1649 1723
      data-modality={@message.modality}

@@ -1651,7 +1725,7 @@ defmodule OpenAgentsWeb.ChatLive do

1651 1725
      <%!-- Floating hover toolbar: copy, copy-link, and the inline timestamp —
1652 1726
            the timestamps' first home in the transcript. Hover/focus-within
1653 1727
            reveals it; touch keeps it visible (Phase D grammar). --%>
1654
      <div class="message-toolbar" role="toolbar" aria-label="Message actions">
1728
      <.message_actions class="message-toolbar" role="toolbar" aria-label="Message actions">
1655 1729
        <time
1656 1730
          :if={@message.inserted_at}
1657 1731
          id={"#{@id}-time"}

@@ -1661,98 +1735,89 @@ defmodule OpenAgentsWeb.ChatLive do

1661 1735
        >
1662 1736
          {Calendar.strftime(@message.inserted_at, "%H:%M")}
1663 1737
        </time>
1664
        <.button
1738
        <.message_action
1665 1739
          id={"#{@id}-copy"}
1666
          variant={:ghost}
1667
          size={:xs}
1740
          label="Copy message"
1668 1741
          class="message-toolbar__button"
1669
          aria-label="Copy message"
1670 1742
          data-copy-kind="message"
1671 1743
        >
1672 1744
          <.icon name="copy" />
1673
        </.button>
1674
        <.button
1745
        </.message_action>
1746
        <.message_action
1675 1747
          id={"#{@id}-copy-link"}
1676
          variant={:ghost}
1677
          size={:xs}
1748
          label="Copy message link"
1678 1749
          class="message-toolbar__button"
1679
          aria-label="Copy message link"
1680 1750
          data-copy-kind="link"
1681 1751
        >
1682 1752
          <.icon name="link" />
1683
        </.button>
1684
      </div>
1685
      <div class="message-body">
1686
        <.badge
1687
          :if={@message.role not in ["assistant", "user"]}
1688
          variant={:dim}
1689
          class="message-provenance"
1690
        >
1691
          {role_label(@message.role)}
1692
        </.badge>
1693
        <section
1694
          :if={@activity != []}
1695
          id={"tool-activity-#{@id}"}
1696
          class="tool-activity"
1697
          role="status"
1698
          aria-live="polite"
1699
          aria-atomic="false"
1700
          aria-label="Sarah activity"
1701
        >
1702
          <.activity_event
1703
            :for={activity <- @activity}
1704
            id={"tool-activity-step-#{activity.id}"}
1705
            activity={activity}
1706
          />
1707
        </section>
1708
        <.badge :if={@message.modality == "voice"} variant={:dim} class="message-provenance">
1709
          VOICE TRANSCRIPT{if @message.interrupted, do: " / INTERRUPTED", else: ""}
1710
        </.badge>
1711
        <%!-- A deep-work report renders as a rollup header: how long the job
1712
              worked and how its steps ended, expanding to the report itself. --%>
1713
        <.event_header
1714
          :if={@rollup}
1715
          id={"job-rollup-#{@id}"}
1716
          status={rollup_status(@rollup)}
1717
          title={rollup_title(@rollup)}
1718
          status_note={rollup_status_note(@rollup)}
1719
          timestamp={@rollup.completed_at}
1720
          class="job-rollup"
1721
        >
1722
          <:chips>
1723
            <.badge :if={@rollup.succeeded_count > 0} variant={:success}>
1724
              {@rollup.succeeded_count} SUCCEEDED
1725
            </.badge>
1726
            <.badge :if={@rollup.refused_count > 0} variant={:warning}>
1727
              {@rollup.refused_count} REFUSED
1728
            </.badge>
1729
          </:chips>
1730
          <div :if={@message.content != ""} class="message-content message-markdown">
1731
            {OpenAgents.Markdown.to_html(@message.content, streaming: @message.status == "streaming")}
1732
          </div>
1733
        </.event_header>
1734
        <div
1735
          :if={
1736
            !@rollup and @message.content != "" and
1737
              not paced_live_transcript?(@message, @paced_items) and
1738
              markdown?(@message)
1739
          }
1753
        </.message_action>
1754
      </.message_actions>
1755
      <.badge
1756
        :if={@message.role not in ["assistant", "user"]}
1757
        variant={:dim}
1758
        class="message-provenance"
1759
      >
1760
        {role_label(@message.role)}
1761
      </.badge>
1762
      <section
1763
        :if={@activity != []}
1764
        id={"tool-activity-#{@id}"}
1765
        class="tool-activity"
1766
        role="status"
1767
        aria-live="polite"
1768
        aria-atomic="false"
1769
        aria-label="Sarah activity"
1770
      >
1771
        <.activity_event
1772
          :for={activity <- @activity}
1773
          id={"tool-activity-step-#{activity.id}"}
1774
          activity={activity}
1775
        />
1776
      </section>
1777
      <.badge :if={@message.modality == "voice"} variant={:dim} class="message-provenance">
1778
        VOICE TRANSCRIPT{if @message.interrupted, do: " / INTERRUPTED", else: ""}
1779
      </.badge>
1780
      <%!-- A deep-work report renders as a rollup header: how long the job
1781
            worked and how its steps ended, expanding to the report itself. --%>
1782
      <.event_header
1783
        :if={@rollup}
1784
        id={"job-rollup-#{@id}"}
1785
        status={rollup_status(@rollup)}
1786
        title={rollup_title(@rollup)}
1787
        status_note={rollup_status_note(@rollup)}
1788
        timestamp={@rollup.completed_at}
1789
        class="job-rollup"
1790
      >
1791
        <:chips>
1792
          <.badge :if={@rollup.succeeded_count > 0} variant={:success}>
1793
            {@rollup.succeeded_count} SUCCEEDED
1794
          </.badge>
1795
          <.badge :if={@rollup.refused_count > 0} variant={:warning}>
1796
            {@rollup.refused_count} REFUSED
1797
          </.badge>
1798
        </:chips>
1799
        <.message_content
1800
          :if={@report?}
1740 1801
          class="message-content message-markdown"
1741
        >
1742
          {OpenAgents.Markdown.to_html(@message.content, streaming: @message.status == "streaming")}
1743
        </div>
1802
          text={@message.content}
1803
          streaming={@streaming?}
1804
        />
1805
      </.event_header>
1806
      <.message_content
1807
        :if={@prose?}
1808
        class="message-content message-markdown"
1809
        text={@message.content}
1810
        streaming={@streaming?}
1811
      />
1812
      <.message_content :if={@plain?}>
1744 1813
        <p
1745
          :if={
1746
            !@rollup and @message.content != "" and
1747
              not paced_live_transcript?(@message, @paced_items) and
1748
              not markdown?(@message)
1749
          }
1750
          class={["message-content", @message.role == "user" && "message-bubble"]}
1814
          class="message-content"
1751 1815
          phx-no-format
1752 1816
        >{@message.content}</p>
1817
      </.message_content>
1818
      <.message_content :if={@paced?}>
1753 1819
        <p
1754
          :if={@message.content != "" and paced_live_transcript?(@message, @paced_items)}
1755
          class={["message-content", @message.role == "user" && "message-bubble"]}
1820
          class="message-content"
1756 1821
          id={"#{@id}-paced"}
1757 1822
          phx-hook="PacedTranscript"
1758 1823
          phx-update="ignore"

@@ -1760,21 +1825,20 @@ defmodule OpenAgentsWeb.ChatLive do

1760 1825
          data-item-id={@message.provider_item_id}
1761 1826
        >
1762 1827
        </p>
1763
        <.badge
1764
          :if={label = status_label(@message.status)}
1765
          variant={message_status_variant(@message.status)}
1766
          class="message-status"
1767
        >
1768
          <.status_indicator
1769
            :if={@message.status == "streaming"}
1770
            state="streaming"
1771
            label={label}
1772
            decorative
1773
          />
1774
          {label}
1775
        </.badge>
1776
      </div>
1777
    </article>
1828
      </.message_content>
1829
      <.badge
1830
        :if={label = status_label(@message.status)}
1831
        variant={message_status_variant(@message.status)}
1832
        class="message-status"
1833
      >
1834
        <.status_indicator :if={@streaming?} state="streaming" label={label} decorative />
1835
        <%!-- Text that is still arriving reads as still arriving: the band
1836
              sweeps across the word while the turn runs, and settles into
1837
              plain text the moment it stops. --%>
1838
        <.shimmer :if={@streaming?} tag="span" text={label} />
1839
        <span :if={!@streaming?}>{label}</span>
1840
      </.badge>
1841
    </.message>
1778 1842
    """
1779 1843
  end
1780 1844

@@ -1786,32 +1850,47 @@ defmodule OpenAgentsWeb.ChatLive do

1786 1850
1787 1851
  defp composer_stack(assigns) do
1788 1852
    ~H"""
1789
    <ul
1790
      :if={@message_queue != []}
1791
      id="message-queue"
1792
      class="message-queue"
1793
      aria-label="Queued messages"
1853
    <%!-- Messages waiting on the running turn, as the AI Elements queue: one
1854
          collapsible section stating how many are lined up, and a row each with
1855
          the control that drops it before it runs. --%>
1856
    <.queue :if={@message_queue != []} id="message-queue" class="message-queue">
1857
      <.queue_section id="message-queue-section" aria-label="Queued messages">
1858
        <.queue_section_trigger>
1859
          <.queue_section_label label="QUEUED" count={length(@message_queue)} />
1860
        </.queue_section_trigger>
1861
        <.queue_section_content>
1862
          <.queue_list>
1863
            <.queue_item :for={item <- @message_queue} id={"queued-#{item.id}"}>
1864
              <div class="flex items-start gap-2">
1865
                <.queue_item_indicator />
1866
                <.queue_item_content>{item.content}</.queue_item_content>
1867
                <.queue_item_actions>
1868
                  <.queue_item_action
1869
                    label="Remove queued message"
1870
                    phx-click="dequeue_message"
1871
                    phx-value-id={item.id}
1872
                  >
1873
                    <.icon name="x" />
1874
                  </.queue_item_action>
1875
                </.queue_item_actions>
1876
              </div>
1877
            </.queue_item>
1878
          </.queue_list>
1879
        </.queue_section_content>
1880
      </.queue_section>
1881
    </.queue>
1882
1883
    <.prompt_input
1884
      id="message-form"
1885
      for={@form}
1886
      class="composer"
1887
      phx-submit="send_message"
1794 1888
    >
1795
      <li :for={item <- @message_queue} id={"queued-#{item.id}"} class="message-queue__item">
1796
        <span class="message-queue__label">QUEUED</span>
1797
        <span class="message-queue__text">{item.content}</span>
1798
        <.button
1799
          variant={:ghost}
1800
          size={:xs}
1801
          class="message-queue__dismiss"
1802
          aria-label="Remove queued message"
1803
          phx-click="dequeue_message"
1804
          phx-value-id={item.id}
1805
        >
1806
          <.icon name="x" />
1807
        </.button>
1808
      </li>
1809
    </ul>
1810
1811
    <.form for={@form} id="message-form" class="composer" phx-submit="send_message">
1812
      <div class="composer-card">
1889
      <%!-- The error is the strip above the control, which is where the input
1890
            group puts anything that belongs to the composer but is not the
1891
            composer. --%>
1892
      <.prompt_input_header :if={@composer_error}>
1813 1893
        <.alert
1814
          :if={@composer_error}
1815 1894
          id="composer-error"
1816 1895
          variant={:danger}
1817 1896
          appearance={:row}

@@ -1820,46 +1899,47 @@ defmodule OpenAgentsWeb.ChatLive do

1820 1899
        >
1821 1900
          <p>{@composer_error}</p>
1822 1901
        </.alert>
1902
      </.prompt_input_header>
1903
1904
      <.prompt_input_textarea
1905
        field={@form[:message]}
1906
        placeholder="Message Sarah"
1907
        aria-label="Message Sarah"
1908
        rows="1"
1909
        maxlength="8000"
1910
        autocomplete="off"
1911
        aria-describedby={if @composer_error, do: "composer-error"}
1912
        phx-mounted={JS.focus()}
1913
        phx-hook=".Composer"
1914
      />
1823 1915
1824
        <div class="composer-grid">
1825
          <.textarea
1826
            id={@form[:message].id}
1827
            name={@form[:message].name}
1828
            value={@form[:message].value}
1829
            class="composer-input"
1830
            placeholder="Message Sarah"
1831
            aria-label="Message Sarah"
1832
            rows="1"
1833
            maxlength="8000"
1834
            autocomplete="off"
1835
            aria-describedby={if @composer_error, do: "composer-error"}
1836
            phx-mounted={JS.focus()}
1837
            phx-hook=".Composer"
1916
      <.prompt_input_toolbar>
1917
        <%!-- Nothing sits at the leading edge of this composer yet — no
1918
              attachments, no model select — so the controls are pushed to the
1919
              trailing edge rather than an empty group being drawn opposite
1920
              them. --%>
1921
        <.prompt_input_tools class="ml-auto">
1922
          <.voice_session_buttons :if={@voice_enabled?} active_turn={@active_turn} />
1923
          <%!-- Stop stays its own control rather than `on_stop` on the submit:
1924
                a message sent while a turn runs queues, so send and stop are
1925
                two live actions, not one control in two states. --%>
1926
          <.prompt_input_button
1927
            :if={@active_turn}
1928
            id="cancel-turn"
1929
            variant={:destructive}
1930
            aria-label="Stop response"
1931
            phx-click="cancel_turn"
1932
          >
1933
            <.icon name="stop" class="size-4" />
1934
          </.prompt_input_button>
1935
          <.prompt_input_submit
1936
            id="send-message"
1937
            status={if @active_turn, do: :submitted, else: :ready}
1938
            label={if @active_turn, do: "Queue message", else: "Send"}
1838 1939
          />
1839
          <div class="composer-trailing">
1840
            <.voice_session_buttons :if={@voice_enabled?} active_turn={@active_turn} />
1841
            <.button
1842
              :if={@active_turn}
1843
              id="cancel-turn"
1844
              variant={:ghost}
1845
              class="send-action send-action--stop"
1846
              aria-label="Stop response"
1847
              phx-click="cancel_turn"
1848
            >
1849
              <.icon name="stop" />
1850
            </.button>
1851
            <.button
1852
              id="send-message"
1853
              type="submit"
1854
              class="send-action"
1855
              aria-label={if @active_turn, do: "Queue message", else: "Send"}
1856
            >
1857
              <.icon name="arrow-up" />
1858
            </.button>
1859
          </div>
1860
        </div>
1861
      </div>
1862
    </.form>
1940
        </.prompt_input_tools>
1941
      </.prompt_input_toolbar>
1942
    </.prompt_input>
1863 1943
    """
1864 1944
  end
1865 1945

@@ -1881,60 +1961,48 @@ defmodule OpenAgentsWeb.ChatLive do

1881 1961
1882 1962
  defp voice_session_buttons(assigns) do
1883 1963
    ~H"""
1884
    <.button
1964
    <.prompt_input_button
1885 1965
      id="voice-start"
1886
      type="button"
1887
      variant={:ghost}
1888
      class="send-action send-action--voice"
1966
      variant={:outline}
1889 1967
      disabled={@active_turn != nil}
1890 1968
      aria-label="Start voice"
1891 1969
    >
1892
      <.icon name="mic" />
1893
    </.button>
1894
    <.button
1970
      <.icon name="mic" class="size-4" />
1971
    </.prompt_input_button>
1972
    <.prompt_input_button
1895 1973
      id="voice-mute"
1896
      type="button"
1897
      variant={:ghost}
1898
      class="send-action send-action--voice"
1974
      variant={:outline}
1899 1975
      hidden
1900 1976
      disabled
1901 1977
      aria-label="Mute microphone"
1902 1978
      aria-pressed="false"
1903 1979
    >
1904
      <.icon name="mic-off" />
1905
    </.button>
1906
    <.button
1980
      <.icon name="mic-off" class="size-4" />
1981
    </.prompt_input_button>
1982
    <.prompt_input_button
1907 1983
      id="voice-interrupt"
1908
      type="button"
1909
      variant={:ghost}
1910
      class="send-action send-action--voice"
1984
      variant={:outline}
1911 1985
      hidden
1912 1986
      disabled
1913 1987
      aria-label="Interrupt Sarah"
1914 1988
    >
1915
      <.icon name="stop" />
1916
    </.button>
1917
    <.button
1918
      id="voice-unlock"
1919
      type="button"
1920
      variant={:ghost}
1921
      class="send-action send-action--voice"
1922
      hidden
1923
      aria-label="Enable audio"
1924
    >
1925
      <.icon name="sound-on-read-out-loud-speaker" />
1926
    </.button>
1927
    <.button
1989
      <.icon name="stop" class="size-4" />
1990
    </.prompt_input_button>
1991
    <.prompt_input_button id="voice-unlock" variant={:outline} hidden aria-label="Enable audio">
1992
      <.icon name="sound-on-read-out-loud-speaker" class="size-4" />
1993
    </.prompt_input_button>
1994
    <%!-- Ending a call is the one destructive control in the row, so it keeps
1995
          the danger hue the retired `.send-action--voice-end` gave it. --%>
1996
    <.prompt_input_button
1928 1997
      id="voice-end"
1929
      type="button"
1930
      variant={:ghost}
1931
      class="send-action send-action--voice send-action--voice-end"
1998
      variant={:outline}
1999
      class="text-danger"
1932 2000
      hidden
1933 2001
      disabled
1934 2002
      aria-label="End voice"
1935 2003
    >
1936
      <.icon name="x-circle" />
1937
    </.button>
2004
      <.icon name="x-circle" class="size-4" />
2005
    </.prompt_input_button>
1938 2006
    """
1939 2007
  end
1940 2008
end
test/openagents_web/components/ai/conversation_test.exs modified +7 -1

@@ -246,10 +246,16 @@ defmodule OpenAgentsWeb.AI.ConversationTest do

246 246
      assert String.trim(text) == "Thinking"
247 247
      assert {"style", "--spread: 16px"} in attributes
248 248
249
      # The sweep is the `shimmer-sweep` keyframes in `openagents.css`, which
250
      # select on this marker. The stand-in `animate-pulse` is gone: two
251
      # animations on one element and the shorthand simply replaces the first,
252
      # so the band would have breathed instead of travelling.
253
      assert {"data-shimmer", "true"} in attributes
254
249 255
      class = Enum.find_value(attributes, fn {name, value} -> name == "class" && value end)
250 256
      assert class =~ "bg-clip-text"
251 257
      assert class =~ "text-transparent"
252
      assert class =~ "animate-pulse"
258
      refute class =~ "animate-pulse"
253 259
      assert class =~ "motion-reduce:animate-none"
254 260
      assert class =~ "var(--color-muted-foreground)"
255 261
    end
test/openagents_web/live/chat_live_test.exs modified +147 -10

@@ -454,9 +454,14 @@ defmodule OpenAgentsWeb.ChatLiveTest do

454 454
455 455
    # Asymmetry carries the roles (DESIGN.md, Message row): the person's
456 456
    # message is the tinted bubble, Sarah's stays bare prose with no bubble
457
    # and no avatar column.
458
    assert has_element?(view, ".message-row--user .message-content.message-bubble")
459
    refute has_element?(view, ".message-row--assistant .message-bubble")
457
    # and no avatar column. The bubble is AI Elements' now — `message/1` marks
458
    # the row `is-user` and `message_content/1`'s `group-[.is-user]:` rules
459
    # paint it — so the marker is what this asserts, not the retired
460
    # `.message-bubble` class the marker replaced.
461
    assert has_element?(view, ~s(.message-row--user[data-from="user"].is-user .message-content))
462
    assert has_element?(view, ".message-row--assistant.is-assistant .message-content")
463
    refute has_element?(view, ".message-row--assistant.is-user")
464
    refute has_element?(view, ".message-bubble")
460 465
    refute has_element?(view, ".message-row .avatar")
461 466
462 467
    persisted =

@@ -536,13 +541,13 @@ defmodule OpenAgentsWeb.ChatLiveTest do

536 541
    refute html =~ step.provider_item_id
537 542
    refute html =~ step.provider_response_id
538 543
539
    # The disclosure anatomy: an aria-expanded chevron button controlling a
540
    # details region that carries the bounded arguments.
541
    assert has_element?(
542
             view,
543
             ~s(#tool-activity-step-#{step.id} button[aria-expanded="false"][aria-controls="tool-activity-step-#{step.id}-details"])
544
           )
545
544
    # The disclosure anatomy: the step is an AI Elements `tool/1`, so the
545
    # control is a native `<summary>` inside a closed `<details>` rather than
546
    # a button carrying `aria-expanded`. The browser supplies the disclosure
547
    # semantics and the keyboard operation; the server writes the initial
548
    # state, and a stream re-insert closes the row again.
549
    assert has_element?(view, "details#tool-activity-step-#{step.id}:not([open]) > summary")
550
    assert has_element?(view, "#tool-activity-step-#{step.id} summary", "Running")
546 551
    assert has_element?(view, "#tool-activity-step-#{step.id}-details")
547 552
    assert html =~ "ARGUMENTS"
548 553

@@ -841,6 +846,138 @@ defmodule OpenAgentsWeb.ChatLiveTest do

841 846
    refute response.resp_body =~ "INTERNAL_TEST_PAYLOAD"
842 847
  end
843 848
849
  # ── The AI Elements composition ─────────────────────────────────────────────
850
  # The surface is built from the ported components rather than from bespoke
851
  # chat CSS. What follows checks the composition itself: that the parts are
852
  # the ported ones, and that the state each of them needs from the LiveView
853
  # actually reaches it. A screenshot cannot tell a `div` that looks like a
854
  # transcript from one the scroll hook can follow.
855
856
  test "the transcript is a conversation of messages, not a hand-rolled scroller", %{conn: conn} do
857
    conn = log_in_github_user(conn, "conversation-composition-browser-00000000")
858
    {:ok, view, _html} = live(conn, ~p"/chat")
859
860
    # The scroller is `conversation/1`: a log region whose inner box scrolls,
861
    # so the return-to-newest control can hold still against the bottom edge.
862
    assert has_element?(view, "#conversation[role=log]")
863
    assert has_element?(view, ~s(#conversation-viewport[data-conversation-viewport="true"]))
864
    assert has_element?(view, ~s(#conversation-scroll-button[data-conversation-scroll-button]))
865
866
    # `.TranscriptScroll` wraps it rather than replacing it: the AI Elements
867
    # hook pins to the newest turn, and this one keeps the copied-link anchor,
868
    # the prepend position, and the server's scroll event.
869
    assert has_element?(view, ~s(#transcript[phx-hook$=".TranscriptScroll"] #conversation))
870
871
    # The turns are `conversation_content/1` holding the stream.
872
    assert has_element?(view, "#conversation #conversation-content.message-list")
873
    assert has_element?(view, ~s(#conversation-content #messages[phx-update="stream"]))
874
875
    # Each turn is `message/1` carrying the marker `message_content/1` reads.
876
    assert has_element?(view, ~s(#messages [data-from="assistant"].is-assistant))
877
    assert has_element?(view, ~s([data-from="assistant"] .message-content))
878
    assert has_element?(view, ~s([data-from="assistant"] .message-toolbar))
879
  end
880
881
  test "the composer is a prompt input that states its submit status", %{conn: conn} do
882
    Application.put_env(:openagents, :test_tool_observer, self())
883
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
884
885
    conn = log_in_github_user(conn, "prompt-input-composition-browser-000000")
886
    {:ok, view, _html} = live(conn, ~p"/chat")
887
888
    # The form is `prompt_input/1`: its hook owns Enter-to-submit and the
889
    # auto-resize the retired `.Composer` hook used to do by hand.
890
    assert has_element?(view, ~s(form#message-form[phx-hook$=".PromptInput"]))
891
    assert has_element?(view, ~s(#message-form[data-submit-on-enter="true"]))
892
893
    # The control is `prompt_input_textarea/1`, which is what makes it the
894
    # input group's control rather than a textarea that happens to be inside.
895
    assert has_element?(view, ~s(#chat_message[data-slot="input-group-control"]))
896
    assert has_element?(view, ~s(#message-form [data-slot="input-group"] #chat_message))
897
898
    # Idle: the send control is a submit button in the ready state.
899
    assert has_element?(view, ~s(#send-message[type="submit"][data-status="ready"]))
900
901
    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
902
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000
903
904
    # A turn is in flight: the status says so, and the control still submits,
905
    # because a message sent now queues rather than being refused.
906
    assert has_element?(view, ~s(#send-message[type="submit"][data-status="submitted"]))
907
    assert has_element?(view, ~s(#send-message[aria-label="Queue message"]))
908
    assert has_element?(view, "#message-form #cancel-turn")
909
910
    view |> element("#cancel-turn") |> render_click()
911
912
    assert eventually(fn -> has_element?(view, ~s(#send-message[data-status="ready"])) end)
913
  end
914
915
  test "queued messages are a queue, and each row still drops itself", %{conn: conn} do
916
    Application.put_env(:openagents, :test_tool_observer, self())
917
    on_exit(fn -> Application.delete_env(:openagents, :test_tool_observer) end)
918
919
    conn = log_in_github_user(conn, "queue-composition-browser-0000000000000")
920
    {:ok, view, _html} = live(conn, ~p"/chat")
921
922
    view |> form("#message-form", chat: %{message: "[cancel-tool-loop]"}) |> render_submit()
923
    assert_receive {:test_tool_executed, _tool_pid, "block", _scope_ref}, 1_000
924
925
    view |> form("#message-form", chat: %{message: "Wait your turn."}) |> render_submit()
926
927
    # `queue/1` holding one `queue_section/1`, which is a native details so the
928
    # disclosure works before any JavaScript loads.
929
    assert has_element?(view, ~s(#message-queue[data-slot="queue"]))
930
    assert has_element?(view, "#message-queue details#message-queue-section[open] > summary")
931
    assert has_element?(view, "#message-queue-section summary", "1 QUEUED")
932
933
    # One `queue_item/1` per waiting message, still carrying its own control.
934
    assert has_element?(view, "#message-queue li[id^=queued-]", "Wait your turn.")
935
936
    assert has_element?(
937
             view,
938
             ~s(#message-queue button[phx-click="dequeue_message"][aria-label="Remove queued message"])
939
           )
940
941
    view
942
    |> element(~s(#message-queue button[phx-click="dequeue_message"]))
943
    |> render_click()
944
945
    refute has_element?(view, "#message-queue")
946
  end
947
948
  test "a tool call is a tool block whose badge states the step's real state", %{conn: conn} do
949
    token = "tool-composition-browser-credential-000000000000"
950
    %{turn: turn, step: step} = begin_tool_turn(token, "composition-query")
951
952
    conn = log_in_github_user(conn, token)
953
    {:ok, view, _html} = live(conn, ~p"/chat")
954
955
    # Requested, before the step starts: `tool_status_badge/1` reads
956
    # `input-streaming`, which it labels Pending.
957
    assert has_element?(view, "details#tool-activity-step-#{step.id} > summary", "Pending")
958
959
    assert {:ok, _running_step, :started} = Conversations.start_tool_step(step)
960
961
    assert eventually(fn ->
962
             has_element?(view, "#tool-activity-step-#{step.id} summary", "Running")
963
           end)
964
965
    assert {:ok, _refused_step} =
966
             Conversations.complete_tool_step(
967
               step,
968
               tool_outcome(step, "refused", "policy_refused")
969
             )
970
971
    # A refusal is `output-denied`, not a generic error: the badge says Denied,
972
    # and the expansion still states the exact durable status word.
973
    assert eventually(fn ->
974
             has_element?(view, "#tool-activity-step-#{step.id} summary", "Denied") and
975
               has_element?(view, "#tool-activity-step-#{step.id}-details", "refused")
976
           end)
977
978
    assert {:ok, _cancelled_turn} = Conversations.cancel_turn(turn)
979
  end
980
844 981
  defp begin_tool_turn(token, query) do
845 982
    assert {:ok, conversation} = Conversations.ensure_conversation(github_user(token))
846 983
    assert {:ok, records} = Conversations.create_turn(conversation, "Recall something.")

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