Consolidate chat into the one sidebar, and give it the stylesheet it never had

5d64c6104af3 · AtlantisPleb · · parent 3a49693988c3

Consolidate chat into the one sidebar, and give it the stylesheet it never had

Chat rendered a second, complete application shell inside the first: its own
brand mark, its own rail with Computers/Memory/Leaderboard/Export, its own
work projection, its own admin nav and its own account footer -- all nested in
the padded main of a layout that already had a sidebar and a command bar. It
was ported from a codebase where chat was the whole application, and the
shell came with it. That nested rail is what floated in the middle of the
page.

`app/1` now takes a `sidebar_extra` slot, so a page with its own destinations
contributes rows to the sidebar the layout owns instead of building a second
one. Chat's rows, its work projection and its admin actions go through it. The
mobile header and the collapse/scrim hook went with the rail they belonged to;
the connection state they carried is now a status line on the surface itself.

The slot is forwarded to `sidebar/1` as a nested slot, not as an attribute.
Passing it as an attribute compiles and renders, but silently loses change
tracking: the work section drew its static markup and then never updated, so a
job broadcast changed nothing on screen. The test that caught it is the one
asserting a job appears without polling.

The second half of the problem was that the conversation had no stylesheet.
Seventy-five classes -- layout, transcript, message chrome, composer, activity,
memory manager, delegation projection -- resolved to no rule at all, because
the port brought the markup and not the CSS. They are written here against the
tokens: one reading measure shared by transcript and composer, the transcript
as the only scrolling region, the composer pinned beneath it. The send button
loses its indigo fill for the inverse control pair; it was the one saturated
element left on a monochrome surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
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/layouts.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified test/openagents_web/account_chrome_test.exs
  • modified test/openagents_web/auth_controller_test.exs
  • modified test/openagents_web/live/chat_live_test.exs

Diff

7 files changed, +831 -297

assets/css/app.css modified +6 -4

@@ -554,8 +554,7 @@

554 554
}
555 555
556 556
.composer-card:focus-within {
557
  border-color: var(--accent-bright);
558
  box-shadow: 0 0 0 2px color-mix(in oklch, var(--accent-bright) 22%, transparent);
557
  border-color: var(--text-dim);
559 558
}
560 559
561 560
.composer-grid {

@@ -602,8 +601,11 @@

602 601
  justify-content: center;
603 602
  border: 0;
604 603
  border-radius: 50%;
605
  background: var(--accent);
606
  color: var(--text-primary);
604
  /* The inverse control pair, not `--accent`. The theme went monochrome; this
605
     button kept an indigo fill and was the one saturated element on the
606
     surface. */
607
  background: var(--control-inverse-bg);
608
  color: var(--control-inverse-fg);
607 609
  cursor: pointer;
608 610
  transition: filter 150ms ease, transform 150ms ease;
609 611
}
assets/css/openagents.css modified +644

@@ -2571,3 +2571,647 @@

2571 2571
    border-radius: 6px;
2572 2572
  }
2573 2573
}
2574
2575
/* ── Chat ─────────────────────────────────────────────────────────────────── */
2576
2577
/* The conversation surface had no stylesheet at all. It was ported from a
2578
 * codebase where it was the whole application, and its layout, message,
2579
 * composer, activity and memory rules did not come with it -- seventy-five
2580
 * classes resolved to nothing, which is why an unstyled column of text sat
2581
 * under a floating rail. What follows is that stylesheet, written against the
2582
 * tokens rather than reconstructed from the original.
2583
 *
2584
 * The surface owns the full height of the main area: the transcript is the
2585
 * only thing that scrolls, and the composer is pinned under it rather than
2586
 * riding at the end of a scrolling page. */
2587
2588
@layer components {
2589
  .chat-shell {
2590
    display: flex;
2591
    min-height: 0;
2592
    flex: 1;
2593
    flex-direction: column;
2594
  }
2595
2596
  .app-main {
2597
    display: flex;
2598
    min-height: 0;
2599
    flex: 1;
2600
    flex-direction: column;
2601
  }
2602
2603
  /* A status line, not a header: it carries the socket's truth and nothing
2604
     else, because the page's name is already in the command bar above it. */
2605
  .chat-status {
2606
    display: flex;
2607
    flex: none;
2608
    align-items: center;
2609
    justify-content: flex-end;
2610
    gap: 8px;
2611
    padding: 8px 24px;
2612
  }
2613
2614
  .connection-state {
2615
    display: inline-flex;
2616
    align-items: center;
2617
    gap: 6px;
2618
  }
2619
2620
  .connection-label {
2621
    color: var(--text-muted);
2622
    font-size: 0.6875rem;
2623
    font-weight: 500;
2624
    letter-spacing: 0.06em;
2625
    line-height: 1rem;
2626
    text-transform: uppercase;
2627
  }
2628
2629
  /* ── Transcript ─────────────────────────────────────────────────────────── */
2630
2631
  .transcript {
2632
    display: flex;
2633
    min-height: 0;
2634
    flex: 1;
2635
    flex-direction: column;
2636
    gap: 24px;
2637
    overflow-y: auto;
2638
    overscroll-behavior: contain;
2639
    padding: 8px 24px 24px;
2640
  }
2641
2642
  .message-list {
2643
    display: flex;
2644
    width: 100%;
2645
    max-width: 46rem;
2646
    flex-direction: column;
2647
    /* A reading column, centred, so a wide window does not stretch prose to
2648
       the full width of a desktop display. The composer below uses the same
2649
       measure, so the two share one axis. */
2650
    margin-inline: auto;
2651
    gap: 20px;
2652
  }
2653
2654
  .history-control {
2655
    display: flex;
2656
    justify-content: center;
2657
    padding-block: 4px;
2658
  }
2659
2660
  .message-markdown {
2661
    color: var(--text-body);
2662
    font-size: 0.9375rem;
2663
    line-height: 1.65;
2664
    word-break: break-word;
2665
  }
2666
2667
  .message-markdown > * + * {
2668
    margin-block-start: 0.75em;
2669
  }
2670
2671
  .message-markdown :is(h1, h2, h3, h4) {
2672
    color: var(--text-primary);
2673
    font-weight: 600;
2674
    line-height: 1.3;
2675
  }
2676
2677
  .message-markdown :is(ul, ol) {
2678
    padding-inline-start: 1.25em;
2679
  }
2680
2681
  .message-markdown li + li {
2682
    margin-block-start: 0.25em;
2683
  }
2684
2685
  .message-markdown a {
2686
    color: var(--text-primary);
2687
    text-decoration: underline;
2688
    text-underline-offset: 2px;
2689
  }
2690
2691
  .message-markdown code {
2692
    border-radius: 4px;
2693
    background: var(--wash-hover);
2694
    padding: 0.125em 0.375em;
2695
    font-size: 0.875em;
2696
  }
2697
2698
  .message-markdown pre {
2699
    position: relative;
2700
    overflow-x: auto;
2701
    border: 1px solid var(--line);
2702
    border-radius: var(--radius-lg);
2703
    background: var(--ink-surface);
2704
    padding: 12px 14px;
2705
  }
2706
2707
  .message-markdown pre code {
2708
    background: transparent;
2709
    padding: 0;
2710
  }
2711
2712
  /* Revealed on hover of the block it belongs to, and always present for
2713
     keyboard reach -- an action that only exists on hover cannot be tabbed to. */
2714
  .code-copy {
2715
    position: absolute;
2716
    top: 6px;
2717
    right: 6px;
2718
    opacity: 0;
2719
    transition: opacity 150ms var(--ease);
2720
  }
2721
2722
  .message-markdown pre:hover .code-copy,
2723
  .code-copy:focus-visible {
2724
    opacity: 1;
2725
  }
2726
2727
  /* ── Message chrome ─────────────────────────────────────────────────────── */
2728
2729
  .message-toolbar {
2730
    display: flex;
2731
    align-items: center;
2732
    gap: 4px;
2733
    color: var(--text-dim);
2734
  }
2735
2736
  .message-toolbar__time {
2737
    font-size: 0.75rem;
2738
    line-height: 1rem;
2739
  }
2740
2741
  /* The row's actions appear on hover of the message, not permanently: they
2742
     are available on every message, and drawing them all would turn the
2743
     transcript into a column of buttons. */
2744
  .message-toolbar__button {
2745
    opacity: 0;
2746
    transition: opacity 150ms var(--ease);
2747
  }
2748
2749
  [class*="message"]:hover .message-toolbar__button,
2750
  .message-toolbar__button:focus-visible {
2751
    opacity: 1;
2752
  }
2753
2754
  .message-status,
2755
  .message-provenance {
2756
    color: var(--text-dim);
2757
    font-size: 0.75rem;
2758
    line-height: 1rem;
2759
  }
2760
2761
  .message-queue {
2762
    display: flex;
2763
    flex-direction: column;
2764
    gap: 6px;
2765
  }
2766
2767
  .message-queue__item {
2768
    display: flex;
2769
    align-items: center;
2770
    gap: 8px;
2771
    border: 1px dashed var(--line);
2772
    border-radius: var(--radius-lg);
2773
    padding: 8px 10px;
2774
  }
2775
2776
  .message-queue__label {
2777
    color: var(--text-dim);
2778
    font-size: 0.6875rem;
2779
    letter-spacing: 0.06em;
2780
    text-transform: uppercase;
2781
  }
2782
2783
  .message-queue__text {
2784
    min-width: 0;
2785
    flex: 1;
2786
    color: var(--text-muted);
2787
    font-size: 0.875rem;
2788
    overflow-wrap: anywhere;
2789
  }
2790
2791
  /* ── Composer ───────────────────────────────────────────────────────────── */
2792
2793
  .composer-region {
2794
    flex: none;
2795
    border-top: 1px solid var(--line);
2796
    background: var(--ink-void);
2797
    padding: 16px 24px 20px;
2798
  }
2799
2800
  .composer-eyebrow {
2801
    display: flex;
2802
    max-width: 46rem;
2803
    align-items: center;
2804
    justify-content: space-between;
2805
    gap: 8px;
2806
    margin-inline: auto;
2807
    padding-block-end: 8px;
2808
    color: var(--text-dim);
2809
    font-size: 0.75rem;
2810
  }
2811
2812
  .control-row {
2813
    display: flex;
2814
    max-width: 46rem;
2815
    align-items: flex-end;
2816
    gap: 8px;
2817
    margin-inline: auto;
2818
    border: 1px solid var(--line-strong);
2819
    border-radius: var(--radius-lg);
2820
    background: var(--ink-surface);
2821
    padding: 6px 6px 6px 12px;
2822
    transition: border-color 150ms var(--ease);
2823
  }
2824
2825
  .control-row:focus-within {
2826
    border-color: var(--text-dim);
2827
  }
2828
2829
  .control-row__input {
2830
    min-height: 32px;
2831
    max-height: 40vh;
2832
    flex: 1;
2833
    resize: none;
2834
    border: 0;
2835
    background: transparent;
2836
    padding-block: 6px;
2837
    color: var(--text-primary);
2838
    font-family: inherit;
2839
    font-size: 0.9375rem;
2840
    line-height: 1.5;
2841
    outline: none;
2842
  }
2843
2844
  .control-row__input::placeholder {
2845
    color: var(--text-dim);
2846
  }
2847
2848
  .voice-controller {
2849
    display: flex;
2850
    flex-direction: column;
2851
    gap: 8px;
2852
  }
2853
2854
  .voice-output {
2855
    display: none;
2856
  }
2857
2858
  /* ── Activity ───────────────────────────────────────────────────────────── */
2859
2860
  .tool-activity {
2861
    display: flex;
2862
    width: 100%;
2863
    max-width: 46rem;
2864
    flex-direction: column;
2865
    margin-inline: auto;
2866
    gap: 4px;
2867
  }
2868
2869
  .job-rollup {
2870
    display: flex;
2871
    align-items: center;
2872
    gap: 8px;
2873
    color: var(--text-dim);
2874
    font-size: 0.75rem;
2875
  }
2876
2877
  /* ── Two-line sidebar rows (the work projection) ─────────────────────────── */
2878
2879
  .sidebar-row__lines {
2880
    display: flex;
2881
    min-width: 0;
2882
    flex-direction: column;
2883
  }
2884
2885
  .sidebar-row__title {
2886
    overflow: hidden;
2887
    color: var(--text-muted);
2888
    font-size: 0.8125rem;
2889
    line-height: 1.25rem;
2890
    text-overflow: ellipsis;
2891
    white-space: nowrap;
2892
  }
2893
2894
  .sidebar-row__meta {
2895
    color: var(--text-dim);
2896
    font-size: 0.75rem;
2897
    line-height: 1rem;
2898
  }
2899
2900
  .sidebar-row__dot {
2901
    flex: none;
2902
  }
2903
2904
  /* A two-line row cannot use the single-line row's height. */
2905
  .sidebar-row:has(.sidebar-row__lines) {
2906
    min-height: 44px;
2907
  }
2908
}
2909
2910
/* Screen-reader-only text. Not `display: none`, which removes it from the
2911
 * accessibility tree along with the visual layout. */
2912
@layer components {
2913
  .visually-hidden {
2914
    position: absolute;
2915
    width: 1px;
2916
    height: 1px;
2917
    overflow: hidden;
2918
    clip-path: inset(50%);
2919
    white-space: nowrap;
2920
  }
2921
}
2922
2923
/* ── Memory manager ───────────────────────────────────────────────────────── */
2924
2925
/* Opened from the sidebar, this replaces the transcript rather than covering
2926
 * it: it is a place you go, not a dialog over where you were, and the row that
2927
 * opens it becomes the row that returns you. */
2928
2929
@layer components {
2930
  .memory-manager {
2931
    display: flex;
2932
    min-height: 0;
2933
    flex: 1;
2934
    flex-direction: column;
2935
    gap: 16px;
2936
    overflow-y: auto;
2937
    padding: 8px 24px 24px;
2938
  }
2939
2940
  .memory-header,
2941
  .memory-records,
2942
  .memory-correction,
2943
  .memory-confirmation,
2944
  .privacy-delete-form {
2945
    width: 100%;
2946
    max-width: 46rem;
2947
    margin-inline: auto;
2948
  }
2949
2950
  .memory-header {
2951
    display: flex;
2952
    align-items: center;
2953
    justify-content: space-between;
2954
    gap: 12px;
2955
  }
2956
2957
  .memory-header__actions {
2958
    display: flex;
2959
    align-items: center;
2960
    gap: 8px;
2961
  }
2962
2963
  .memory-records {
2964
    display: flex;
2965
    flex-direction: column;
2966
    gap: 8px;
2967
  }
2968
2969
  .memory-record__claim {
2970
    color: var(--text-body);
2971
    font-size: 0.9375rem;
2972
    line-height: 1.5;
2973
  }
2974
2975
  .memory-record__meta,
2976
  .memory-record__sources {
2977
    color: var(--text-dim);
2978
    font-size: 0.75rem;
2979
    line-height: 1rem;
2980
  }
2981
2982
  .memory-record__controls {
2983
    display: flex;
2984
    flex-wrap: wrap;
2985
    align-items: center;
2986
    gap: 6px;
2987
  }
2988
2989
  /* Deletion is stated in the control's colour, not only in its label. */
2990
  .memory-record__destructive {
2991
    color: var(--danger);
2992
  }
2993
2994
  .memory-correction,
2995
  .memory-confirmation {
2996
    display: flex;
2997
    flex-direction: column;
2998
    gap: 8px;
2999
    border: 1px solid var(--line);
3000
    border-radius: var(--radius-lg);
3001
    background: var(--ink-surface);
3002
    padding: 12px;
3003
  }
3004
3005
  .memory-confirmation__actions {
3006
    display: flex;
3007
    align-items: center;
3008
    gap: 8px;
3009
  }
3010
}
3011
3012
/* ── Delegation projection ────────────────────────────────────────────────── */
3013
3014
/* One projection with two placements: a rail beside the transcript on wide
3015
 * screens, the same content inline at the transcript tail below that. Both are
3016
 * ephemeral -- present only while a delegation is live or recently finished. */
3017
3018
@layer components {
3019
  .delegation-rail {
3020
    display: none;
3021
  }
3022
3023
  @media (min-width: 1280px) {
3024
    .delegation-rail {
3025
      display: flex;
3026
      width: 300px;
3027
      flex: none;
3028
      flex-direction: column;
3029
      border-left: 1px solid var(--line);
3030
      background: var(--ink-void);
3031
    }
3032
3033
    /* Below this width the same content renders inline instead, so showing
3034
       both would state the delegation twice. */
3035
    .delegation-inline {
3036
      display: none;
3037
    }
3038
  }
3039
3040
  .delegation-rail__header {
3041
    display: flex;
3042
    flex: none;
3043
    align-items: center;
3044
    justify-content: space-between;
3045
    gap: 8px;
3046
    border-bottom: 1px solid var(--line);
3047
    padding: 8px 12px 8px 16px;
3048
  }
3049
3050
  .delegation-rail__label {
3051
    color: var(--text-muted);
3052
    font-size: 0.6875rem;
3053
    font-weight: 600;
3054
    letter-spacing: 0.06em;
3055
    text-transform: uppercase;
3056
  }
3057
3058
  .delegation-rail__body {
3059
    display: flex;
3060
    min-height: 0;
3061
    flex: 1;
3062
    flex-direction: column;
3063
    gap: 12px;
3064
    overflow-y: auto;
3065
    padding: 12px 16px;
3066
  }
3067
3068
  .delegation-rail[data-collapsed="true"] .delegation-rail__body {
3069
    display: none;
3070
  }
3071
3072
  .delegation-rail__glyph-collapse,
3073
  .delegation-rail[data-collapsed="true"] .delegation-rail__glyph-expand {
3074
    display: inline-flex;
3075
  }
3076
3077
  .delegation-rail__glyph-expand,
3078
  .delegation-rail[data-collapsed="true"] .delegation-rail__glyph-collapse {
3079
    display: none;
3080
  }
3081
3082
  .delegation-inline {
3083
    width: 100%;
3084
    max-width: 46rem;
3085
    margin-inline: auto;
3086
    border: 1px solid var(--line);
3087
    border-radius: var(--radius-lg);
3088
    background: var(--ink-surface);
3089
    padding: 12px;
3090
  }
3091
3092
  .delegation-inline__details {
3093
    display: flex;
3094
    flex-direction: column;
3095
    gap: 8px;
3096
  }
3097
3098
  .delegation-inline__outcome {
3099
    color: var(--text-muted);
3100
    font-size: 0.8125rem;
3101
  }
3102
3103
  .delegation-live {
3104
    display: flex;
3105
    flex-direction: column;
3106
    gap: 6px;
3107
  }
3108
3109
  .delegation-live__header {
3110
    display: flex;
3111
    align-items: center;
3112
    gap: 8px;
3113
  }
3114
3115
  .delegation-live__subject {
3116
    min-width: 0;
3117
    flex: 1;
3118
    overflow: hidden;
3119
    color: var(--text-primary);
3120
    font-size: 0.8125rem;
3121
    font-weight: 500;
3122
    text-overflow: ellipsis;
3123
    white-space: nowrap;
3124
  }
3125
3126
  .delegation-live__machine,
3127
  .delegation-live__elapsed {
3128
    color: var(--text-dim);
3129
    font-family: var(--font-mono, ui-monospace, monospace);
3130
    font-size: 0.75rem;
3131
    font-variant-numeric: tabular-nums;
3132
  }
3133
3134
  .delegation-log {
3135
    display: flex;
3136
    max-height: 40vh;
3137
    flex-direction: column;
3138
    gap: 2px;
3139
    overflow-y: auto;
3140
    color: var(--text-muted);
3141
    font-family: var(--font-mono, ui-monospace, monospace);
3142
    font-size: 0.75rem;
3143
    line-height: 1.4;
3144
  }
3145
3146
  .delegation-truncated {
3147
    color: var(--text-dim);
3148
    font-size: 0.6875rem;
3149
  }
3150
3151
  .delegation-summary__lines {
3152
    display: flex;
3153
    min-width: 0;
3154
    flex-direction: column;
3155
  }
3156
3157
  .delegation-summary__title {
3158
    overflow: hidden;
3159
    color: var(--text-muted);
3160
    font-size: 0.8125rem;
3161
    text-overflow: ellipsis;
3162
    white-space: nowrap;
3163
  }
3164
3165
  .delegation-summary__meta {
3166
    color: var(--text-dim);
3167
    font-size: 0.75rem;
3168
  }
3169
3170
  .delegation-summary__dot {
3171
    flex: none;
3172
  }
3173
3174
  /* A superseded summary is kept rather than removed -- it happened -- but it
3175
     is stated more quietly than the one that replaced it. */
3176
  .delegation-summary--superseded {
3177
    opacity: 0.6;
3178
  }
3179
3180
  .delegation-summary__dismiss,
3181
  .message-queue__dismiss {
3182
    flex: none;
3183
    margin-inline-start: auto;
3184
  }
3185
}
3186
3187
/* ── Composer action states ───────────────────────────────────────────────── */
3188
3189
/* One control, three jobs: send, stop a running turn, and end a voice session.
3190
 * Each states its job in shape and colour rather than only in its glyph. */
3191
3192
@layer components {
3193
  .send-action--stop {
3194
    background: var(--danger);
3195
    color: var(--control-inverse-fg);
3196
  }
3197
3198
  .send-action--voice,
3199
  .send-action--voice-end {
3200
    background: transparent;
3201
    border: 1px solid var(--line-strong);
3202
    color: var(--icon-primary);
3203
  }
3204
3205
  .send-action--voice-end {
3206
    border-color: var(--danger);
3207
    color: var(--danger);
3208
  }
3209
3210
  .voice-controller--in-composer {
3211
    width: 100%;
3212
  }
3213
3214
  .tool-activity--live {
3215
    padding-block-start: 4px;
3216
  }
3217
}
lib/openagents_web/components/layouts.ex modified +28 -30

@@ -41,20 +41,36 @@ defmodule OpenAgentsWeb.Layouts do

41 41
42 42
  attr :subtitle, :string, default: nil, doc: "the page subtitle to display in the command bar"
43 43
44
  attr :flush, :boolean,
45
    default: false,
46
    doc: "surface owns the full main area: no padding, no scroll of its own"
47
44 48
  slot :inner_block, required: true
45 49
50
  slot :sidebar_extra,
51
    doc: """
52
    Rows this page contributes to the application sidebar.
53
54
    A page with its own destinations adds them to the one sidebar rather than
55
    rendering a second one inside the content area. Chat did the latter, which
56
    put a whole nested application shell -- brand, rail, account footer --
57
    inside the padded main of the shell that already had one.
58
    """
59
46 60
  def app(assigns) do
47 61
    ~H"""
48 62
    <div class="h-screen flex overflow-hidden bg-background">
49
      <%= if @current_scope do %>
50
        <.sidebar current_scope={@current_scope} />
51
      <% end %>
63
      <.sidebar :if={@current_scope} current_scope={@current_scope}>
64
        <:extra>{render_slot(@sidebar_extra)}</:extra>
65
      </.sidebar>
52 66
53 67
      <div class="flex-1 min-w-0 flex flex-col h-screen">
54 68
        <.openagents_command_bar current_scope={@current_scope} title={@title} subtitle={@subtitle} />
55 69
56 70
        <main class={[
57
          "flex-1 min-w-0 overflow-y-auto overscroll-none p-4",
71
          "flex-1 min-w-0",
72
          @flush && "flex min-h-0 flex-col overflow-hidden",
73
          !@flush && "overflow-y-auto overscroll-none p-4",
58 74
          @current_scope && "bg-background"
59 75
        ]}>
60 76
          <%= if @current_scope do %>

@@ -480,9 +496,13 @@ defmodule OpenAgentsWeb.Layouts do

480 496
    """
481 497
  end
482 498
499
  attr :current_scope, :map, required: true
500
501
  slot :extra, doc: "rows contributed by the current page"
502
483 503
  defp sidebar(assigns) do
484 504
    ~H"""
485
    <aside class="sidebar hidden lg:flex">
505
    <aside id="sidebar" class="sidebar hidden lg:flex">
486 506
      <Layouts.sidebar_brand />
487 507
488 508
      <nav class="sidebar-nav" aria-label="OpenAgents surfaces">

@@ -502,31 +522,9 @@ defmodule OpenAgentsWeb.Layouts do

502 522
        />
503 523
      </nav>
504 524
505
      <%!--
506
      <div class="sidebar-sections">
507
        <section class="sidebar-section" aria-label="Work">
508
          <h2 class="sidebar-section-label">WORK</h2>
509
          <div class="sidebar-row sidebar-row--static">
510
            <span class="sidebar-row__content">
511
              <span class="sidebar-row__icon"><.icon name="bolt" /></span>
512
              <span class="sidebar-row__label">Explore repo</span>
513
            </span>
514
          </div>
515
          <div class="sidebar-row sidebar-row--static">
516
            <span class="sidebar-row__content">
517
              <span class="sidebar-row__icon"><.icon name="bolt" /></span>
518
              <span class="sidebar-row__label">Plan change</span>
519
            </span>
520
          </div>
521
          <div class="sidebar-row sidebar-row--static">
522
            <span class="sidebar-row__content">
523
              <span class="sidebar-row__icon"><.icon name="bolt" /></span>
524
              <span class="sidebar-row__label">Run tests</span>
525
            </span>
526
          </div>
527
        </section>
528
      </div>
529
      --%>
525
      <%!-- Rows the current page contributes. Chat's destinations, work
526
      projections and admin actions arrive here instead of in a second rail. --%>
527
      {render_slot(@extra)}
530 528
531 529
      <Layouts.sidebar_footer />
532 530
    </aside>
lib/openagents_web/live/chat_live.ex modified +115 -236

@@ -800,26 +800,21 @@ defmodule OpenAgentsWeb.ChatLive do

800 800
  @impl true
801 801
  def render(assigns) do
802 802
    ~H"""
803
    <Layouts.app flash={@flash} title="Chat" current_scope={@current_scope}>
804
      <div
805
        id="openagents-app"
806
        class="app-shell chat-shell"
807
        data-mobile-open="false"
808
        phx-hook=".SidebarShell"
809
      >
810
        <.sidebar
803
    <Layouts.app flash={@flash} title="Chat" current_scope={@current_scope} flush>
804
      <:sidebar_extra>
805
        <.chat_sidebar_rows
811 806
          current_user={@current_user}
812 807
          memory_open?={@memory_open?}
813 808
          reset_enabled?={@reset_enabled?}
814 809
          recent_jobs={@recent_jobs}
815 810
        />
811
      </:sidebar_extra>
816 812
817
        <%!-- Mobile only: closes the overlay on tap. The menu button remains the
818
              accessible toggle; this is a pointer convenience, not a control. --%>
819
        <div id="sidebar-scrim" class="sidebar-scrim" aria-hidden="true"></div>
820
813
      <div id="openagents-app" class="chat-shell">
821 814
        <main class="app-main">
822
          <.mobile_header />
815
          <header class="chat-status">
816
            <.connection_state id_prefix="chat-connection" />
817
          </header>
823 818
824 819
          <section
825 820
            :if={!@memory_open?}

@@ -948,80 +943,6 @@ defmodule OpenAgentsWeb.ChatLive do

948 943
        />
949 944
      </div>
950 945
951
      <script :type={Phoenix.LiveView.ColocatedHook} name=".SidebarShell">
952
        export default {
953
          storageKey: "sarah:sidebar",
954
          mounted() {
955
            this.collapsed = localStorage.getItem(this.storageKey) === "collapsed"
956
            this.mobileOpen = false
957
            this.apply()
958
            // Delegated so the listeners survive any LiveView patch inside the
959
            // shell. Collapse and the mobile overlay are pure client state; the
960
            // server never needs to know.
961
            this.el.addEventListener("click", event => {
962
              if (event.target.closest("#sidebar-toggle")) {
963
                this.collapsed = !this.collapsed
964
                localStorage.setItem(this.storageKey, this.collapsed ? "collapsed" : "expanded")
965
                this.apply()
966
              } else if (event.target.closest("#mobile-menu")) {
967
                this.mobileOpen = !this.mobileOpen
968
                this.apply()
969
              } else if (
970
                event.target.closest("#sidebar-scrim") ||
971
                  (this.mobileOpen && event.target.closest(".sidebar-row__hit"))
972
              ) {
973
                this.mobileOpen = false
974
                this.apply()
975
              }
976
            })
977
            this.onKeydown = event => {
978
              if (event.key === "Escape" && this.mobileOpen) {
979
                this.mobileOpen = false
980
                this.apply()
981
              }
982
            }
983
            window.addEventListener("keydown", this.onKeydown)
984
            // Scroll does not bubble, so the section area's scrolling is
985
            // observed with a capturing listener on the shell; it survives
986
            // LiveView patches the same way the click delegation does.
987
            this.el.addEventListener("scroll", event => {
988
              if (event.target.id === "sidebar-sections") this.applyScrollEdges()
989
            }, { capture: true, passive: true })
990
            this.applyScrollEdges()
991
          },
992
          updated() {
993
            this.apply()
994
            this.applyScrollEdges()
995
          },
996
          destroyed() { window.removeEventListener("keydown", this.onKeydown) },
997
          // A sticky section label earns its hairline only after rows have
998
          // actually scrolled beneath it (DESIGN.md, scroll-edge hairline).
999
          applyScrollEdges() {
1000
            const container = this.el.querySelector("#sidebar-sections")
1001
            if (!container) return
1002
            const top = container.getBoundingClientRect().top
1003
            container.querySelectorAll(".sidebar-section").forEach(section => {
1004
              const label = section.querySelector(".sidebar-section-label")
1005
              if (!label) return
1006
              if (section.getBoundingClientRect().top < top - 0.5) {
1007
                label.setAttribute("data-scroll-edge", "stuck")
1008
              } else {
1009
                label.removeAttribute("data-scroll-edge")
1010
              }
1011
            })
1012
          },
1013
          apply() {
1014
            const sidebar = this.el.querySelector("#sidebar")
1015
            if (sidebar) sidebar.setAttribute("data-state", this.collapsed ? "collapsed" : "expanded")
1016
            this.el.setAttribute("data-mobile-open", this.mobileOpen ? "true" : "false")
1017
            const toggle = this.el.querySelector("#sidebar-toggle")
1018
            if (toggle) toggle.setAttribute("aria-expanded", this.collapsed ? "false" : "true")
1019
            const menu = this.el.querySelector("#mobile-menu")
1020
            if (menu) menu.setAttribute("aria-expanded", this.mobileOpen ? "true" : "false")
1021
          }
1022
        }
1023
      </script>
1024
1025 946
      <script :type={Phoenix.LiveView.ColocatedHook} name=".TranscriptScroll">
1026 947
        export default {
1027 948
          mounted() {

@@ -1366,149 +1287,127 @@ defmodule OpenAgentsWeb.ChatLive do

1366 1287
  # stretched-anchor pattern: the hit control owns the whole row and the
1367 1288
  # accessible name, the visible content beneath is pointer-transparent, and
1368 1289
  # any future trailing control floats back above it at its own z-index.
1369
  defp sidebar(assigns) do
1290
  defp chat_sidebar_rows(assigns) do
1370 1291
    ~H"""
1371
    <aside id="sidebar" class="sidebar" data-state="expanded" aria-label="OpenAgents surfaces">
1372
      <header class="sidebar-header">
1373
        <span class="brand-name">OpenAgents</span>
1374
        <.button
1375
          id="sidebar-toggle"
1376
          variant={:ghost}
1377
          size={:sm}
1378
          class="sidebar-toggle !size-9 !min-h-0 !min-w-0 !p-0"
1379
          aria-label="Toggle sidebar"
1380
          aria-expanded="true"
1381
          aria-controls="sidebar"
1382
        >
1383
          <.icon name="sidebar" class="sidebar-toggle__collapse !text-[1.25rem]" />
1384
          <.icon name="sidebar" class="sidebar-toggle__expand !text-[1.25rem]" />
1385
        </.button>
1386
      </header>
1292
    <nav id="sidebar-nav" class="sidebar-nav" aria-label="Chat">
1293
      <div class="sidebar-row">
1294
        <.link
1295
          id="open-computers"
1296
          navigate="/computers"
1297
          class="sidebar-row__hit"
1298
          aria-label="Computers"
1299
        ></.link>
1300
        <span class="sidebar-row__content">
1301
          <span class="sidebar-row__icon"><.icon name="desktop" /></span>
1302
          <span class="sidebar-row__label">Computers</span>
1303
        </span>
1304
      </div>
1387 1305
1388
      <nav id="sidebar-nav" class="sidebar-nav" aria-label="OpenAgents surfaces">
1389
        <div class="sidebar-row">
1390
          <.link
1391
            id="open-computers"
1392
            navigate="/computers"
1393
            class="sidebar-row__hit"
1394
            aria-label="Computers"
1395
          ></.link>
1396
          <span class="sidebar-row__content">
1397
            <span class="sidebar-row__icon"><.icon name="desktop" /></span>
1398
            <span class="sidebar-row__label">Computers</span>
1306
      <div class="sidebar-row" data-selected={@memory_open?}>
1307
        <button
1308
          id="toggle-memory"
1309
          type="button"
1310
          class="sidebar-row__hit"
1311
          phx-click="toggle_memory"
1312
          aria-expanded={to_string(@memory_open?)}
1313
          aria-controls="memory-manager"
1314
          aria-label={if @memory_open?, do: "Return to conversation", else: "Memory"}
1315
        ></button>
1316
        <span class="sidebar-row__content">
1317
          <span class="sidebar-row__icon">
1318
            <.icon name={if @memory_open?, do: "arrow-left", else: "brain"} />
1399 1319
          </span>
1400
        </div>
1401
1402
        <div class="sidebar-row" data-selected={@memory_open?}>
1403
          <button
1404
            id="toggle-memory"
1405
            type="button"
1406
            class="sidebar-row__hit"
1407
            phx-click="toggle_memory"
1408
            aria-expanded={to_string(@memory_open?)}
1409
            aria-controls="memory-manager"
1410
            aria-label={if @memory_open?, do: "Return to conversation", else: "Memory"}
1411
          ></button>
1412
          <span class="sidebar-row__content">
1413
            <span class="sidebar-row__icon">
1414
              <.icon name={if @memory_open?, do: "arrow-left", else: "brain"} />
1415
            </span>
1416
            <span class="sidebar-row__label">
1417
              {if @memory_open?, do: "Return to conversation", else: "Memory"}
1418
            </span>
1320
          <span class="sidebar-row__label">
1321
            {if @memory_open?, do: "Return to conversation", else: "Memory"}
1419 1322
          </span>
1420
        </div>
1323
        </span>
1324
      </div>
1421 1325
1422
        <div class="sidebar-row">
1423
          <.link
1424
            id="open-leaderboard"
1425
            navigate="/leaderboard"
1426
            class="sidebar-row__hit"
1427
            aria-label="Leaderboard"
1428
          ></.link>
1429
          <span class="sidebar-row__content">
1430
            <span class="sidebar-row__icon"><.icon name="trophy-top" /></span>
1431
            <span class="sidebar-row__label">Leaderboard</span>
1432
          </span>
1433
        </div>
1326
      <div class="sidebar-row">
1327
        <.link
1328
          id="open-leaderboard"
1329
          navigate="/leaderboard"
1330
          class="sidebar-row__hit"
1331
          aria-label="Leaderboard"
1332
        ></.link>
1333
        <span class="sidebar-row__content">
1334
          <span class="sidebar-row__icon"><.icon name="trophy-top" /></span>
1335
          <span class="sidebar-row__label">Leaderboard</span>
1336
        </span>
1337
      </div>
1434 1338
1435
        <div class="sidebar-row">
1436
          <.link
1437
            id="export-atif"
1438
            href="/data/export/atif"
1439
            download
1440
            class="sidebar-row__hit"
1441
            aria-label="Export"
1442
          ></.link>
1443
          <span class="sidebar-row__content">
1444
            <span class="sidebar-row__icon"><.icon name="download" /></span>
1445
            <span class="sidebar-row__label">Export</span>
1446
          </span>
1447
        </div>
1448
      </nav>
1339
      <div class="sidebar-row">
1340
        <.link
1341
          id="export-atif"
1342
          href="/data/export/atif"
1343
          download
1344
          class="sidebar-row__hit"
1345
          aria-label="Export"
1346
        ></.link>
1347
        <span class="sidebar-row__content">
1348
          <span class="sidebar-row__icon"><.icon name="download" /></span>
1349
          <span class="sidebar-row__label">Export</span>
1350
        </span>
1351
      </div>
1352
    </nav>
1449 1353
1450
      <%!-- Calls and work: bounded, durable-backed projections (last eight
1354
    <%!-- Calls and work: bounded, durable-backed projections (last eight
1451 1355
            each), refreshed by the same PubSub broadcasts that drive the
1452 1356
            transcript. A row whose evidence is a durable transcript message is
1453 1357
            a stretched anchor to it; a row with no target is stated, not
1454 1358
            linked. Empty sections keep their labels and say so honestly. --%>
1455
      <div id="sidebar-sections" class="sidebar-sections">
1456
        <section :if={@recent_jobs != []} id="sidebar-work" class="sidebar-section" aria-label="Work">
1457
          <h2 class="sidebar-section-label scroll-edge-hairline">WORK</h2>
1458
          <.sidebar_status_row
1459
            :for={job <- @recent_jobs}
1460
            id={"sidebar-job-#{job.id}"}
1461
            target_message_id={job.report_message_id}
1462
            dot_state={job_dot_state(job)}
1463
            title={job_title(job)}
1464
            meta={job_meta(job)}
1465
            data-status={job.status}
1466
          />
1467
        </section>
1359
    <div id="sidebar-sections" class="sidebar-sections">
1360
      <section :if={@recent_jobs != []} id="sidebar-work" class="sidebar-section" aria-label="Work">
1361
        <h2 class="sidebar-section-label scroll-edge-hairline">WORK</h2>
1362
        <.sidebar_status_row
1363
          :for={job <- @recent_jobs}
1364
          id={"sidebar-job-#{job.id}"}
1365
          target_message_id={job.report_message_id}
1366
          dot_state={job_dot_state(job)}
1367
          title={job_title(job)}
1368
          meta={job_meta(job)}
1369
          data-status={job.status}
1370
        />
1371
      </section>
1372
    </div>
1373
1374
    <nav
1375
      :if={Accounts.admin?(@current_user) or @reset_enabled?}
1376
      id="sidebar-admin"
1377
      class="sidebar-nav"
1378
      aria-label="Administration and data"
1379
    >
1380
      <div :if={Accounts.admin?(@current_user)} class="sidebar-row">
1381
        <.link id="open-admin" navigate="/admin" class="sidebar-row__hit" aria-label="Admin"></.link>
1382
        <span class="sidebar-row__content">
1383
          <span class="sidebar-row__icon"><.icon name="shield-lock" /></span>
1384
          <span class="sidebar-row__label">Admin</span>
1385
        </span>
1468 1386
      </div>
1469 1387
1470
      <nav
1471
        :if={Accounts.admin?(@current_user) or @reset_enabled?}
1472
        id="sidebar-admin"
1473
        class="sidebar-nav"
1474
        aria-label="Administration and data"
1388
      <.form
1389
        :if={@reset_enabled?}
1390
        for={%{}}
1391
        id="reset-conversation-form"
1392
        action="/data/reset"
1393
        method="delete"
1394
        class="sidebar-row"
1475 1395
      >
1476
        <div :if={Accounts.admin?(@current_user)} class="sidebar-row">
1477
          <.link id="open-admin" navigate="/admin" class="sidebar-row__hit" aria-label="Admin"></.link>
1478
          <span class="sidebar-row__content">
1479
            <span class="sidebar-row__icon"><.icon name="shield-lock" /></span>
1480
            <span class="sidebar-row__label">Admin</span>
1481
          </span>
1482
        </div>
1483
1484
        <.form
1485
          :if={@reset_enabled?}
1486
          for={%{}}
1487
          id="reset-conversation-form"
1488
          action="/data/reset"
1489
          method="delete"
1490
          class="sidebar-row"
1491
        >
1492
          <.button
1493
            id="reset-conversation"
1494
            variant={:ghost}
1495
            size={:sm}
1496
            type="submit"
1497
            data-confirm="Delete every message and memory for this account?"
1498
            aria-label="Reset"
1499
            class="sidebar-row__hit"
1500
          >{" "}</.button>
1501
          <span class="sidebar-row__content">
1502
            <span class="sidebar-row__icon"><.icon name="trash" /></span>
1503
            <span class="sidebar-row__label">Reset</span>
1504
          </span>
1505
        </.form>
1506
      </nav>
1507
1508
      <footer class="sidebar-footer">
1509
        <Layouts.account_control current_user={@current_user} context={:row} />
1510
      </footer>
1511
    </aside>
1396
        <.button
1397
          id="reset-conversation"
1398
          variant={:ghost}
1399
          size={:sm}
1400
          type="submit"
1401
          data-confirm="Delete every message and memory for this account?"
1402
          aria-label="Reset"
1403
          class="sidebar-row__hit"
1404
        >{" "}</.button>
1405
        <span class="sidebar-row__content">
1406
          <span class="sidebar-row__icon"><.icon name="trash" /></span>
1407
          <span class="sidebar-row__label">Reset</span>
1408
        </span>
1409
      </.form>
1410
    </nav>
1512 1411
    """
1513 1412
  end
1514 1413

@@ -1618,26 +1517,6 @@ defmodule OpenAgentsWeb.ChatLive do

1618 1517
1619 1518
  # Below 768px the sidebar hides behind this slim row: wordmark, connection
1620 1519
  # state, and the menu button that reveals the sidebar as an overlay.
1621
  defp mobile_header(assigns) do
1622
    ~H"""
1623
    <header class="mobile-header">
1624
      <.button
1625
        id="mobile-menu"
1626
        variant={:ghost}
1627
        size={:sm}
1628
        class="mobile-menu-button"
1629
        aria-label="Toggle sidebar"
1630
        aria-expanded="false"
1631
        aria-controls="sidebar"
1632
      >
1633
        <.icon name="menu" />
1634
      </.button>
1635
      <span class="brand-name">SARAH</span>
1636
      <.connection_state id_prefix="mobile-connection" />
1637
    </header>
1638
    """
1639
  end
1640
1641 1520
  attr :id_prefix, :string, required: true
1642 1521
1643 1522
  # Connection state is driven by the LiveView socket through element-level
test/openagents_web/account_chrome_test.exs modified +7 -5

@@ -57,16 +57,18 @@ defmodule OpenAgentsWeb.AccountChromeTest do

57 57
  end
58 58
59 59
  describe "connection chrome" do
60
    test "the mobile header states the socket's truth through element-level bindings", %{
60
    test "the chat status line states the socket's truth through element-level bindings", %{
61 61
      conn: conn
62 62
    } do
63 63
      conn = log_in_github_user(conn, "connection-user")
64 64
      {:ok, view, _html} = live(conn, ~p"/chat")
65 65
66
      # The slim mobile header carries the socket's truth under its own ids.
67
      assert has_element?(view, "#mobile-connection-indicator[phx-connected][phx-disconnected]")
68
      assert has_element?(view, "#mobile-connection-connected", "CONNECTED")
69
      assert has_element?(view, "#mobile-connection-reconnecting[hidden]", "RECONNECTING")
66
      # Chat's status line carries the socket's truth under its own ids. It
67
      # replaced a mobile header whose other job -- a menu button and a second
68
      # brand mark -- belonged to a rail chat no longer renders.
69
      assert has_element?(view, "#chat-connection-indicator[phx-connected][phx-disconnected]")
70
      assert has_element?(view, "#chat-connection-connected", "CONNECTED")
71
      assert has_element?(view, "#chat-connection-reconnecting[hidden]", "RECONNECTING")
70 72
    end
71 73
  end
72 74
test/openagents_web/auth_controller_test.exs modified +3 -1

@@ -50,7 +50,9 @@ defmodule OpenAgentsWeb.AuthControllerTest do

50 50
    refute cookie =~ "ephemeral-github-token"
51 51
52 52
    first_browser = authenticated |> recycle() |> get(~p"/chat")
53
    assert html_response(first_browser, 200) =~ "SARAH"
53
    # The brand mark is the application's, once, in the sidebar. Chat used to
54
    # render a second "SARAH" mark in a header belonging to its own rail.
55
    assert html_response(first_browser, 200) =~ "OpenAgents"
54 56
    conversation = Conversations.get_conversation_for_user(user)
55 57
56 58
    second_browser =
test/openagents_web/live/chat_live_test.exs modified +28 -21

@@ -61,7 +61,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

61 61
62 62
    assert has_element?(
63 63
             view,
64
             "#sidebar[data-state] #sidebar-nav > .sidebar-row:first-child #open-computers"
64
             "#sidebar #sidebar-nav > .sidebar-row:first-child #open-computers"
65 65
           )
66 66
67 67
    assert has_element?(

@@ -72,28 +72,30 @@ defmodule OpenAgentsWeb.ChatLiveTest do

72 72
    assert has_element?(view, ~s(#sidebar-nav #open-leaderboard[aria-label="Leaderboard"]))
73 73
  end
74 74
75
  test "the sidebar replaces the command bar on the conversation", %{conn: conn} do
75
  test "chat contributes its rows to the one application sidebar", %{conn: conn} do
76 76
    conn = log_in_github_user(conn, "sidebar-shell-browser")
77 77
    {:ok, view, _html} = live(conn, ~p"/chat")
78 78
79
    # The conversation's chrome is the sidebar (the 2026-08-17 reversal); the
80
    # command bar remains the leaderboard's and the operator panel's frame.
81
    refute has_element?(view, "header.command-bar")
82
    assert has_element?(view, "#sidebar[data-state]")
83
84
    # Every control the command bar used to carry is reachable, with an
85
    # accessible name on each stretched hit target.
79
    # Chat used to render a second, complete application shell inside the
80
    # first: its own brand, rail and account footer, nested in the padded main
81
    # of a layout that already had all three. It now contributes rows to the
82
    # sidebar the layout owns, so there is exactly one of each.
83
    assert has_element?(view, "#sidebar")
84
    assert view |> render() |> String.split("<aside") |> length() == 2
85
    refute has_element?(view, "#sidebar-scrim")
86
    refute has_element?(view, "#mobile-menu")
87
    refute has_element?(view, "#sidebar-toggle")
88
89
    # Every destination chat used to carry in its own rail is still reachable,
90
    # with an accessible name on each stretched hit target.
86 91
    assert has_element?(view, ~s(#sidebar #toggle-memory[aria-label="Memory"]))
87 92
    assert has_element?(view, "#sidebar a#export-atif[href='/data/export/atif'][download]")
88
    assert has_element?(view, "#sidebar footer.sidebar-footer #account-menu-trigger")
89
90
    # The collapse toggle and the mobile menu button are icon-only, so both
91
    # must carry their names on the control.
92
    assert has_element?(view, "#sidebar-toggle[aria-label][aria-controls=sidebar]")
93
    assert has_element?(view, "#mobile-menu[aria-label][aria-controls=sidebar]")
93
    assert has_element?(view, "#sidebar #sidebar-sections")
94 94
95
    # The empty section area exists for Phase E4; it holds no fake content.
96
    assert has_element?(view, "#sidebar-sections")
95
    # Identity is the command bar's, once, rather than a second account
96
    # control in a second footer.
97
    assert has_element?(view, "#account-bar-trigger")
98
    refute has_element?(view, "#sidebar #account-menu-trigger")
97 99
  end
98 100
99 101
  test "the admin row renders only for an operator", %{conn: conn} do

@@ -141,7 +143,7 @@ defmodule OpenAgentsWeb.ChatLiveTest do

141 143
    conn = log_in_github_user(conn, "live-browser")
142 144
    assert {:ok, view, html} = live(conn, ~p"/chat")
143 145
144
    assert html =~ "SIMPLY SARAH" or html =~ "SARAH"
146
    assert html =~ "OpenAgents"
145 147
    assert html =~ "Message Sarah"
146 148
    assert html =~ "Hello. I&#39;m Sarah—an OpenAgent. What are we working on?"
147 149
    assert html =~ ~s(href="/favicon.ico")

@@ -155,11 +157,16 @@ defmodule OpenAgentsWeb.ChatLiveTest do

155 157
156 158
    assert has_element?(
157 159
             view,
158
             "#account-menu-trigger[popovertarget='account-menu'] img[src='#{user.github_avatar_url}']"
160
             "#account-bar-trigger[popovertarget='account-bar-menu'] img[src='#{user.github_avatar_url}']"
161
           )
162
163
    assert has_element?(view, "#account-bar-menu[popover=auto][role=menu]")
164
165
    assert has_element?(
166
             view,
167
             "#account-bar-menu form[action='/logout'] button[role=menuitem]"
159 168
           )
160 169
161
    assert has_element?(view, "#account-menu[popover=auto][role=menu]")
162
    assert has_element?(view, "#logout-form[action='/logout'] #logout[role=menuitem]")
163 170
    refute html =~ "CONNECTED / THIS BROWSER"
164 171
  end
165 172

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