Show Admin on every sidebar, and stop collapsed sections flashing open

a27857aac751 · AtlantisPleb · · parent efaecf176332

Show Admin on every sidebar, and stop collapsed sections flashing open

Two bugs with the same shape: a default that made a mistake look like a
legitimate state.

`sidebar_footer/1` defaulted its user to nil, so a forgotten attribute was
indistinguishable from a signed-out visitor. The docs and component-library
layouts had both forgotten it, and an operator browsing either surface
silently had no Admin row. The attribute is required now -- nil is still fine
to pass, it just has to be said, and the catalogue demo says it.

The collapsed-section state had the same problem one layer out. It lived only
in the browser and was applied by a hook after the document arrived. Several
sidebar destinations live in different live sessions, so moving between them
is a full page load: the server, knowing nothing, sent every section open, the
browser painted that, and the hook shut it a frame later. The reader watched a
section they had collapsed expand and collapse on every navigation.

A preference that has to affect the first paint has to travel with the
request. The hook writes a cookie, a plug reads it into the session, and the
section renders collapsed to begin with -- there is no longer a wrong state to
correct. The cookie is reader-supplied, so it is bounded on the way in: junk,
wrong shapes, oversized payloads, and keys that are not section ids are
dropped.

The state is threaded to the shell explicitly rather than read ambiently, so a
page cannot quietly lose it. All three guards were verified by injecting the
defect each exists to catch.

Also: the dev server reads PORT, defaulting to 4000. Moving it aside so
another agent can verify against the canonical port should not mean editing
checked-in config.

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 config/dev.exs
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/components.html.heex
  • modified lib/openagents_web/components/layouts/docs.html.heex
  • modified lib/openagents_web/live/admin_forge_live.ex
  • modified lib/openagents_web/live/admin_live.ex
  • modified lib/openagents_web/live/admin_recordings_live.ex
  • modified lib/openagents_web/live/admin_scv_accounts_live.ex
  • modified lib/openagents_web/live/api_tokens_live.ex
  • modified lib/openagents_web/live/assignee_index_live.ex
  • modified lib/openagents_web/live/changelog_live.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified lib/openagents_web/live/code_blob_live.ex
  • modified lib/openagents_web/live/code_commit_live.ex
  • modified lib/openagents_web/live/code_repo_live.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/computers_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_new_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/leaderboard_live.ex
  • modified lib/openagents_web/live/memory_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/network_status_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/live/ui_gallery_live.ex
  • modified lib/openagents_web/live/voice_spike_live.ex
  • added lib/openagents_web/plugs/sidebar_sections.ex
  • modified lib/openagents_web/router.ex
  • modified lib/openagents_web/user_auth.ex
  • added test/openagents_web/sidebar_state_test.exs

Diff

34 files changed, +500 -46

config/dev.exs modified +4 -1

@@ -38,7 +38,10 @@ config :openagents, :github_token_decryption_keys, %{}

38 38
config :openagents, OpenAgentsWeb.Endpoint,
39 39
  # Binding to loopback ipv4 address prevents access from other machines.
40 40
  # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
41
  http: [ip: {127, 0, 0, 1}],
41
  # 4000 unless `PORT` says otherwise. Another agent verifying a git-forge
42
  # receipt needs the canonical port free, and moving this server aside should
43
  # not mean editing checked-in config to do it.
44
  http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
42 45
  check_origin: false,
43 46
  code_reloader: true,
44 47
  debug_errors: true,
lib/openagents_web/components/layouts.ex modified +67 -15

@@ -22,7 +22,7 @@ defmodule OpenAgentsWeb.Layouts do

22 22
23 23
  ## Examples
24 24
25
      <Layouts.app flash={@flash}>
25
      <Layouts.app flash={@flash} sidebar_sections={assigns[:sidebar_sections]}>
26 26
        <h1>Content</h1>
27 27
      </Layouts.app>
28 28

@@ -33,6 +33,15 @@ defmodule OpenAgentsWeb.Layouts do

33 33
    default: nil,
34 34
    doc: "the current [scope](https://phoenix.hexdocs.pm/scopes.html)"
35 35
36
  attr :sidebar_sections, :any,
37
    default: %{},
38
    doc: """
39
    Which sidebar sections the reader has collapsed, from their cookie. Passed
40
    down rather than read ambiently so a page that renders a sidebar cannot
41
    quietly lose it -- `test/openagents_web/sidebar_state_test.exs` fails on a
42
    call site that omits it.
43
    """
44
36 45
  attr :wide, :boolean,
37 46
    default: false,
38 47
    doc: "use a wider content column for catalog and list surfaces"

@@ -67,7 +76,11 @@ defmodule OpenAgentsWeb.Layouts do

67 76
  def app(assigns) do
68 77
    ~H"""
69 78
    <div class="h-screen flex overflow-hidden bg-background">
70
      <.sidebar :if={@current_scope} current_scope={@current_scope}>
79
      <.sidebar
80
        :if={@current_scope}
81
        current_scope={@current_scope}
82
        sidebar_sections={@sidebar_sections || %{}}
83
      >
71 84
        <:extra>{render_slot(@sidebar_extra)}</:extra>
72 85
      </.sidebar>
73 86

@@ -208,7 +221,14 @@ defmodule OpenAgentsWeb.Layouts do

208 221
  The component library is advertised outside production only. It documents
209 222
  the parts a page is built from rather than anything a visitor came for.
210 223
  """
211
  attr :current_user, :map, default: nil, doc: "used only to decide whether admin shows"
224
  # Required, with no default. Defaulting to nil made a forgotten attribute
225
  # look exactly like a signed-out visitor, and the docs and component-library
226
  # layouts both forgot it -- so an operator browsing either surface silently
227
  # lost the Admin row. `nil` is still a fine thing to pass; it just has to be
228
  # said.
229
  attr :current_user, :any,
230
    required: true,
231
    doc: "the scope whose access decides the admin row; nil for a visitor"
212 232
213 233
  def sidebar_footer(assigns) do
214 234
    assigns =

@@ -252,7 +272,19 @@ defmodule OpenAgentsWeb.Layouts do

252 272
  the section you are reading would hide your own location.
253 273
  """
254 274
  attr :title, :string, required: true
255
  attr :open, :boolean, default: false, doc: "first paint only; the reader's choice wins after"
275
276
  attr :open, :boolean,
277
    default: false,
278
    doc: "the seed, used until the reader has said otherwise for this section"
279
280
  attr :state, :map,
281
    default: %{},
282
    doc: """
283
    The reader's collapsed/expanded sections, keyed by element id, as carried
284
    from their cookie by `OpenAgentsWeb.Plugs.SidebarSections`. Applied here,
285
    on the server, so a full page load paints what they chose rather than
286
    painting the seed and having a hook correct it a frame later.
287
    """
256 288
257 289
  attr :id, :string, default: nil, doc: "defaults to a slug of the title"
258 290
  slot :inner_block, required: true

@@ -262,7 +294,11 @@ defmodule OpenAgentsWeb.Layouts do

262 294
    # assigns as nil, so `assign_new` would consider it present and never
263 295
    # compute. A hook without a DOM id silently does not run.
264 296
    assigns =
265
      assign(assigns, :id, assigns.id || "sidebar-section-" <> section_slug(assigns.title))
297
      assigns
298
      |> assign(:id, assigns.id || "sidebar-section-" <> section_slug(assigns.title))
299
      |> then(fn assigns ->
300
        assign(assigns, :open, Map.get(assigns.state, assigns.id, assigns.open))
301
      end)
266 302
267 303
    ~H"""
268 304
    <details

@@ -288,23 +324,31 @@ defmodule OpenAgentsWeb.Layouts do

288 324
    a section the reader had closed. With no JavaScript the seed governs and
289 325
    the sidebar still works. --%>
290 326
    <script :type={Phoenix.LiveView.ColocatedHook} name=".SidebarSection">
291
      const KEY = "sidebar-sections"
327
      // A cookie rather than sessionStorage, because the server has to know.
328
      // Several sidebar destinations live in different live sessions, so
329
      // moving between them is a full page load; state the server cannot read
330
      // arrives too late, and the reader watches a section they collapsed
331
      // paint open and then shut. See `OpenAgentsWeb.Plugs.SidebarSections`.
332
      const KEY = "sidebar_sections"
292 333
293 334
      const read = () => {
335
        const entry = document.cookie
336
          .split("; ")
337
          .find((part) => part.startsWith(KEY + "="))
338
        if (!entry) return {}
294 339
        try {
295
          return JSON.parse(sessionStorage.getItem(KEY)) || {}
340
          return JSON.parse(decodeURIComponent(entry.slice(KEY.length + 1))) || {}
296 341
        } catch (_error) {
297 342
          return {}
298 343
        }
299 344
      }
300 345
301 346
      const write = (state) => {
302
        try {
303
          sessionStorage.setItem(KEY, JSON.stringify(state))
304
        } catch (_error) {
305
          // A full or unavailable store costs the reader their open sections
306
          // on the next navigation, which is the behaviour without the hook.
307
        }
347
        const value = encodeURIComponent(JSON.stringify(state))
348
        // Session-scoped, same-site, and readable by script because script is
349
        // what maintains it. It holds which sidebar sections are open, so it
350
        // is not worth protecting and must not be sent cross-site.
351
        document.cookie = KEY + "=" + value + "; path=/; samesite=lax"
308 352
      }
309 353
310 354
      export default {

@@ -542,11 +586,14 @@ defmodule OpenAgentsWeb.Layouts do

542 586
  end
543 587
544 588
  attr :current_scope, :map, required: true
589
  attr :sidebar_sections, :map, required: true
545 590
546 591
  slot :extra, doc: "rows contributed by the current page"
547 592
548 593
  defp sidebar(assigns) do
549
    assigns = assign(assigns, :agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
594
    assigns =
595
      assigns
596
      |> assign(:agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
550 597
551 598
    ~H"""
552 599
    <aside id="sidebar" class="sidebar hidden lg:flex">

@@ -573,7 +620,12 @@ defmodule OpenAgentsWeb.Layouts do

573 620
      the two things it can reach -- and reading as a group says that in a way
574 621
      six flat rows cannot. Open by default: grouping is for orientation here,
575 622
      not for hiding. --%>
576
      <Layouts.sidebar_section :if={@agent_surfaces?} title="Sarah" open>
623
      <Layouts.sidebar_section
624
        :if={@agent_surfaces?}
625
        title="Sarah"
626
        open
627
        state={@sidebar_sections}
628
      >
577 629
        <Layouts.sidebar_link path={~p"/chat"} label="Chat" icon="chat" patchable={false} />
578 630
        <Layouts.sidebar_link
579 631
          path={~p"/computers"}
lib/openagents_web/components/layouts/components.html.heex modified +7 -2

@@ -7,6 +7,7 @@

7 7
      <Layouts.sidebar_section
8 8
        :for={section <- OpenAgentsWeb.ComponentCatalog.sections()}
9 9
        title={section.title}
10
        state={assigns[:sidebar_sections] || %{}}
10 11
        open={Enum.any?(section.items, &(&1.slug == @active_component))}
11 12
      >
12 13
        <Layouts.sidebar_link

@@ -19,7 +20,11 @@

19 20
        />
20 21
      </Layouts.sidebar_section>
21 22
22
      <Layouts.sidebar_section title="Reference" open={@active_component == :icons}>
23
      <Layouts.sidebar_section
24
        title="Reference"
25
        state={assigns[:sidebar_sections] || %{}}
26
        open={@active_component == :icons}
27
      >
23 28
        <Layouts.sidebar_link
24 29
          path={~p"/components/icons"}
25 30
          label="Icons"

@@ -30,7 +35,7 @@

30 35
      </Layouts.sidebar_section>
31 36
    </nav>
32 37
33
    <Layouts.sidebar_footer />
38
    <Layouts.sidebar_footer current_user={@current_scope} />
34 39
  </aside>
35 40
36 41
  <div class="docs-column">
lib/openagents_web/components/layouts/docs.html.heex modified +2 -1

@@ -6,6 +6,7 @@

6 6
      <Layouts.sidebar_section
7 7
        :for={section <- OpenAgentsWeb.DocsCatalog.sections()}
8 8
        title={section.title}
9
        state={assigns[:sidebar_sections] || %{}}
9 10
        open={Enum.any?(section.items, &(&1.slug == @active_page))}
10 11
      >
11 12
        <Layouts.sidebar_link

@@ -19,7 +20,7 @@

19 20
      </Layouts.sidebar_section>
20 21
    </nav>
21 22
22
    <Layouts.sidebar_footer />
23
    <Layouts.sidebar_footer current_user={@current_scope} />
23 24
  </aside>
24 25
25 26
  <div class="docs-column">
lib/openagents_web/live/admin_forge_live.ex modified +5 -1

@@ -109,7 +109,11 @@ defmodule OpenAgentsWeb.AdminForgeLive do

109 109
  @impl true
110 110
  def render(assigns) do
111 111
    ~H"""
112
    <Layouts.app flash={@flash} current_scope={@current_scope}>
112
    <Layouts.app
113
      flash={@flash}
114
      sidebar_sections={assigns[:sidebar_sections]}
115
      current_scope={@current_scope}
116
    >
113 117
      <main id="admin-forge-page" class="app-shell admin-shell">
114 118
        <section class="admin" aria-label="Forge deploy lane">
115 119
          <h1>Forge — {@repo}</h1>
lib/openagents_web/live/admin_live.ex modified +7 -1

@@ -70,7 +70,13 @@ defmodule OpenAgentsWeb.AdminLive do

70 70
  @impl true
71 71
  def render(assigns) do
72 72
    ~H"""
73
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Admin" wide>
73
    <Layouts.app
74
      flash={@flash}
75
      sidebar_sections={assigns[:sidebar_sections]}
76
      current_scope={@current_scope}
77
      title="Admin"
78
      wide
79
    >
74 80
      <section class="panel" aria-label="Accounts">
75 81
        <header class="panel__header">
76 82
          <h1 class="panel__title">Accounts</h1>
lib/openagents_web/live/admin_recordings_live.ex modified +7 -1

@@ -69,7 +69,13 @@ defmodule OpenAgentsWeb.AdminRecordingsLive do

69 69
  @impl true
70 70
  def render(assigns) do
71 71
    ~H"""
72
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Voice recordings" wide>
72
    <Layouts.app
73
      flash={@flash}
74
      sidebar_sections={assigns[:sidebar_sections]}
75
      current_scope={@current_scope}
76
      title="Voice recordings"
77
      wide
78
    >
73 79
      <section id="admin-recordings-page" class="panel" aria-label="Voice call recordings">
74 80
        <header class="panel__header">
75 81
          <div>
lib/openagents_web/live/admin_scv_accounts_live.ex modified +6 -1

@@ -104,7 +104,12 @@ defmodule OpenAgentsWeb.AdminScvAccountsLive do

104 104
  @impl true
105 105
  def render(assigns) do
106 106
    ~H"""
107
    <Layouts.app flash={@flash} current_scope={@current_scope} title="SCV Codex accounts">
107
    <Layouts.app
108
      flash={@flash}
109
      sidebar_sections={assigns[:sidebar_sections]}
110
      current_scope={@current_scope}
111
      title="SCV Codex accounts"
112
    >
108 113
      <main id="admin-scv-accounts-page" class="app-shell admin-shell">
109 114
        <section class="admin space-y-8" aria-labelledby="scv-codex-heading">
110 115
          <header class="admin-heading">
lib/openagents_web/live/api_tokens_live.ex modified +5 -1

@@ -46,7 +46,11 @@ defmodule OpenAgentsWeb.ApiTokensLive do

46 46
  @impl true
47 47
  def render(assigns) do
48 48
    ~H"""
49
    <Layouts.app flash={@flash} current_scope={@current_scope}>
49
    <Layouts.app
50
      flash={@flash}
51
      sidebar_sections={assigns[:sidebar_sections]}
52
      current_scope={@current_scope}
53
    >
50 54
      <main id="api-token-settings" class="mx-auto w-full max-w-4xl space-y-8 px-4 py-10">
51 55
        <header class="space-y-2">
52 56
          <h1 class="text-3xl font-semibold tracking-tight">API tokens</h1>
lib/openagents_web/live/assignee_index_live.ex modified +5 -1

@@ -27,7 +27,11 @@ defmodule OpenAgentsWeb.AssigneeIndexLive do

27 27
28 28
  def render(assigns) do
29 29
    ~H"""
30
    <Layouts.app flash={@flash} current_scope={@current_scope}>
30
    <Layouts.app
31
      flash={@flash}
32
      sidebar_sections={assigns[:sidebar_sections]}
33
      current_scope={@current_scope}
34
    >
31 35
      <h1 class="text-2xl font-bold mb-4">Assignees</h1>
32 36
33 37
      <%= if @assignees == [] do %>
lib/openagents_web/live/changelog_live.ex modified +6 -1

@@ -90,7 +90,12 @@ defmodule OpenAgentsWeb.ChangelogLive do

90 90
  @impl true
91 91
  def render(assigns) do
92 92
    ~H"""
93
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Changelog">
93
    <Layouts.app
94
      flash={@flash}
95
      sidebar_sections={assigns[:sidebar_sections]}
96
      current_scope={@current_scope}
97
      title="Changelog"
98
    >
94 99
      <main id="changelog-page" class="app-shell changelog-shell">
95 100
        <section class="changelog" aria-label="Changelog">
96 101
          <header class="changelog-heading">
lib/openagents_web/live/chat_live.ex modified +7 -1

@@ -626,7 +626,13 @@ defmodule OpenAgentsWeb.ChatLive do

626 626
  @impl true
627 627
  def render(assigns) do
628 628
    ~H"""
629
    <Layouts.app flash={@flash} title="Chat" current_scope={@current_scope} flush>
629
    <Layouts.app
630
      flash={@flash}
631
      sidebar_sections={assigns[:sidebar_sections]}
632
      title="Chat"
633
      current_scope={@current_scope}
634
      flush
635
    >
630 636
      <%!-- Export is the conversation's action, so it belongs beside the
631 637
      conversation's name rather than as a permanent sidebar row competing with
632 638
      the places you can go. --%>
lib/openagents_web/live/code_blob_live.ex modified +6 -1

@@ -75,7 +75,12 @@ defmodule OpenAgentsWeb.CodeBlobLive do

75 75
  @impl true
76 76
  def render(assigns) do
77 77
    ~H"""
78
    <Layouts.app flash={@flash} current_scope={assigns[:current_scope]} title="Code">
78
    <Layouts.app
79
      flash={@flash}
80
      sidebar_sections={assigns[:sidebar_sections]}
81
      current_scope={assigns[:current_scope]}
82
      title="Code"
83
    >
79 84
      <main id="code-blob-page" class="app-shell code-shell">
80 85
        <section class="code" aria-label="File view">
81 86
          <header class="code-heading">
lib/openagents_web/live/code_commit_live.ex modified +6 -1

@@ -127,7 +127,12 @@ defmodule OpenAgentsWeb.CodeCommitLive do

127 127
  @impl true
128 128
  def render(assigns) do
129 129
    ~H"""
130
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Commit">
130
    <Layouts.app
131
      flash={@flash}
132
      sidebar_sections={assigns[:sidebar_sections]}
133
      current_scope={@current_scope}
134
      title="Commit"
135
    >
131 136
      <main id="code-commit-page" class="app-shell code-shell">
132 137
        <section class="code" aria-label="Commit view">
133 138
          <header class="code-heading">
lib/openagents_web/live/code_repo_live.ex modified +6 -1

@@ -55,7 +55,12 @@ defmodule OpenAgentsWeb.CodeRepoLive do

55 55
  @impl true
56 56
  def render(assigns) do
57 57
    ~H"""
58
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Code">
58
    <Layouts.app
59
      flash={@flash}
60
      sidebar_sections={assigns[:sidebar_sections]}
61
      current_scope={@current_scope}
62
      title="Code"
63
    >
59 64
      <main id="code-repo-page" class="app-shell code-shell">
60 65
        <section class="code" aria-label="Repository">
61 66
          <header class="code-heading">
lib/openagents_web/live/components_live.ex modified +1 -1

@@ -1127,7 +1127,7 @@ defmodule OpenAgentsWeb.ComponentsLive do

1127 1127
        visitor sees.
1128 1128
      </p>
1129 1129
      <div class="demo-frame">
1130
        <Layouts.sidebar_footer />
1130
        <Layouts.sidebar_footer current_user={nil} />
1131 1131
      </div>
1132 1132
    </div>
1133 1133
    """
lib/openagents_web/live/computers_live.ex modified +6 -1

@@ -192,7 +192,12 @@ defmodule OpenAgentsWeb.ComputersLive do

192 192
  @impl true
193 193
  def render(assigns) do
194 194
    ~H"""
195
    <Layouts.app flash={@flash} current_scope={assigns[:current_scope]} title="Computers">
195
    <Layouts.app
196
      flash={@flash}
197
      sidebar_sections={assigns[:sidebar_sections]}
198
      current_scope={assigns[:current_scope]}
199
      title="Computers"
200
    >
196 201
      <main id="computers-page" class="app-shell computers-shell">
197 202
        <section id="computers-manager" class="computers" aria-label="Paired computers">
198 203
          <div class="computers__inner">
lib/openagents_web/live/home_live.ex modified +13 -2

@@ -69,7 +69,13 @@ defmodule OpenAgentsWeb.HomeLive do

69 69
  @impl true
70 70
  def render(%{current_user: user} = assigns) when not is_nil(user) do
71 71
    ~H"""
72
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Home" wide>
72
    <Layouts.app
73
      flash={@flash}
74
      sidebar_sections={assigns[:sidebar_sections]}
75
      current_scope={@current_scope}
76
      title="Home"
77
      wide
78
    >
73 79
      <div class="dashboard">
74 80
        <div class="dashboard__main">
75 81
          <section class="panel" aria-labelledby="dashboard-issues">

@@ -188,7 +194,12 @@ defmodule OpenAgentsWeb.HomeLive do

188 194
189 195
  def render(assigns) do
190 196
    ~H"""
191
    <Layouts.app flash={@flash} current_scope={@current_scope} flush>
197
    <Layouts.app
198
      flash={@flash}
199
      sidebar_sections={assigns[:sidebar_sections]}
200
      current_scope={@current_scope}
201
      flush
202
    >
192 203
      <div class="landing-page">
193 204
        <Landing.layout_lines />
194 205
lib/openagents_web/live/issue_index_live.ex modified +7 -1

@@ -80,7 +80,13 @@ defmodule OpenAgentsWeb.IssueIndexLive do

80 80
81 81
  def render(assigns) do
82 82
    ~H"""
83
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Issues" wide>
83
    <Layouts.app
84
      flash={@flash}
85
      sidebar_sections={assigns[:sidebar_sections]}
86
      current_scope={@current_scope}
87
      title="Issues"
88
      wide
89
    >
84 90
      <Circle.issue_toolbar>
85 91
        <:leading>
86 92
          <Circle.view_tabs>
lib/openagents_web/live/issue_new_live.ex modified +5 -1

@@ -76,7 +76,11 @@ defmodule OpenAgentsWeb.IssueNewLive do

76 76
77 77
  def render(assigns) do
78 78
    ~H"""
79
    <Layouts.app flash={@flash} current_scope={@current_scope}>
79
    <Layouts.app
80
      flash={@flash}
81
      sidebar_sections={assigns[:sidebar_sections]}
82
      current_scope={@current_scope}
83
    >
80 84
      <h1 class="text-2xl font-bold mb-4">New issue</h1>
81 85
82 86
      <.form
lib/openagents_web/live/issue_show_live.ex modified +6 -1

@@ -162,7 +162,12 @@ defmodule OpenAgentsWeb.IssueShowLive do

162 162
163 163
  def render(assigns) do
164 164
    ~H"""
165
    <Layouts.app flash={@flash} current_scope={@current_scope} wide>
165
    <Layouts.app
166
      flash={@flash}
167
      sidebar_sections={assigns[:sidebar_sections]}
168
      current_scope={@current_scope}
169
      wide
170
    >
166 171
      <Circle.issue_detail>
167 172
        <:heading>
168 173
          <.form
lib/openagents_web/live/label_index_live.ex modified +5 -1

@@ -47,7 +47,11 @@ defmodule OpenAgentsWeb.LabelIndexLive do

47 47
48 48
  def render(assigns) do
49 49
    ~H"""
50
    <Layouts.app flash={@flash} current_scope={@current_scope}>
50
    <Layouts.app
51
      flash={@flash}
52
      sidebar_sections={assigns[:sidebar_sections]}
53
      current_scope={@current_scope}
54
    >
51 55
      <div class="flex items-center justify-between mb-4">
52 56
        <h1 class="text-2xl font-bold">Labels</h1>
53 57
      </div>
lib/openagents_web/live/leaderboard_live.ex modified +6 -1

@@ -37,7 +37,12 @@ defmodule OpenAgentsWeb.LeaderboardLive do

37 37
  @impl true
38 38
  def render(assigns) do
39 39
    ~H"""
40
    <Layouts.app flash={@flash} current_scope={assigns[:current_scope]} title="Leaderboard">
40
    <Layouts.app
41
      flash={@flash}
42
      sidebar_sections={assigns[:sidebar_sections]}
43
      current_scope={assigns[:current_scope]}
44
      title="Leaderboard"
45
    >
41 46
      <main id="leaderboard-page" class="app-shell leaderboard-shell">
42 47
        <section class="leaderboard" aria-label="Token leaderboard">
43 48
          <header class="leaderboard-heading">
lib/openagents_web/live/memory_live.ex modified +7 -1

@@ -47,7 +47,13 @@ defmodule OpenAgentsWeb.MemoryLive do

47 47
  @impl true
48 48
  def render(assigns) do
49 49
    ~H"""
50
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Memory" wide>
50
    <Layouts.app
51
      flash={@flash}
52
      sidebar_sections={assigns[:sidebar_sections]}
53
      current_scope={@current_scope}
54
      title="Memory"
55
      wide
56
    >
51 57
      <.memory_manager
52 58
        memory_records={@memory_records}
53 59
        memory_status={@memory_status}
lib/openagents_web/live/milestone_index_live.ex modified +5 -1

@@ -85,7 +85,11 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

85 85
86 86
  def render(assigns) do
87 87
    ~H"""
88
    <Layouts.app flash={@flash} current_scope={@current_scope}>
88
    <Layouts.app
89
      flash={@flash}
90
      sidebar_sections={assigns[:sidebar_sections]}
91
      current_scope={@current_scope}
92
    >
89 93
      <div class="flex items-center justify-between mb-4">
90 94
        <h1 class="text-2xl font-bold">Milestones</h1>
91 95
      </div>
lib/openagents_web/live/network_status_live.ex modified +6 -1

@@ -274,7 +274,12 @@ defmodule OpenAgentsWeb.NetworkStatusLive do

274 274
  @impl true
275 275
  def render(assigns) do
276 276
    ~H"""
277
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Status">
277
    <Layouts.app
278
      flash={@flash}
279
      sidebar_sections={assigns[:sidebar_sections]}
280
      current_scope={@current_scope}
281
      title="Status"
282
    >
278 283
      <main id="network-status-page" class="app-shell status-shell">
279 284
        <section class="status" aria-label="Network status">
280 285
          <header class="status-heading">
lib/openagents_web/live/project_index_live.ex modified +7 -1

@@ -62,7 +62,13 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

62 62
63 63
  def render(assigns) do
64 64
    ~H"""
65
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Projects" wide>
65
    <Layouts.app
66
      flash={@flash}
67
      sidebar_sections={assigns[:sidebar_sections]}
68
      current_scope={@current_scope}
69
      title="Projects"
70
      wide
71
    >
66 72
      <.form
67 73
        for={@form}
68 74
        id="new-project-form"
lib/openagents_web/live/project_show_live.ex modified +5 -1

@@ -71,7 +71,11 @@ defmodule OpenAgentsWeb.ProjectShowLive do

71 71
72 72
  def render(assigns) do
73 73
    ~H"""
74
    <Layouts.app flash={@flash} current_scope={@current_scope}>
74
    <Layouts.app
75
      flash={@flash}
76
      sidebar_sections={assigns[:sidebar_sections]}
77
      current_scope={@current_scope}
78
    >
75 79
      <div class="flex items-center justify-between mb-4">
76 80
        <h1 class="text-2xl font-bold">{@project.title}</h1>
77 81
        <.link
lib/openagents_web/live/voice_spike_live.ex modified +5 -1

@@ -11,7 +11,11 @@ defmodule OpenAgentsWeb.VoiceSpikeLive do

11 11
  @impl true
12 12
  def render(assigns) do
13 13
    ~H"""
14
    <Layouts.app flash={@flash} current_scope={@current_scope}>
14
    <Layouts.app
15
      flash={@flash}
16
      sidebar_sections={assigns[:sidebar_sections]}
17
      current_scope={@current_scope}
18
    >
15 19
      <main id="voice-spike" class="app-shell">
16 20
        <header class="command-bar">
17 21
          <div class="brand-lockup"><span class="brand-name">SARAH / VOICE SPIKE</span></div>
lib/openagents_web/plugs/sidebar_sections.ex added +86

@@ -0,0 +1,86 @@

1
defmodule OpenAgentsWeb.Plugs.SidebarSections do
2
  @moduledoc """
3
  Carries which sidebar sections the reader has collapsed into the session, so
4
  the server can render them collapsed on the first paint.
5
6
  The reader's choice used to live only in the browser, applied by a hook after
7
  the document arrived. That is correct but late: several sidebar destinations
8
  cross live sessions, so moving between them is a full page load, and the
9
  server -- knowing nothing -- sent every section open. The reader watched a
10
  section they had collapsed appear and then collapse again on every
11
  navigation.
12
13
  A preference that has to affect the first paint has to travel with the
14
  request, which for a browser means a cookie. The hook writes one; this reads
15
  it into the session, where a LiveView mount can see it.
16
17
  The value is a reader's own UI state, so it is treated as untrusted input and
18
  bounded on the way in: unparseable JSON, a non-object, oversized content, or
19
  keys that are not section ids are dropped rather than passed inward.
20
  """
21
22
  @behaviour Plug
23
24
  @cookie "sidebar_sections"
25
  @session_key "sidebar_sections"
26
27
  # A section id per collapsible group, and no group has many. Well past what
28
  # the application renders, and far short of anything worth storing.
29
  @maximum_sections 64
30
  @maximum_bytes 2_048
31
  @id_pattern ~r/^sidebar-section-[a-z0-9-]{1,64}$/
32
33
  @impl Plug
34
  def init(options), do: options
35
36
  @impl Plug
37
  def call(conn, _options) do
38
    conn = Plug.Conn.fetch_cookies(conn)
39
    sections = parse(conn.cookies[@cookie])
40
41
    # Written only on change. The session cookie is re-signed and re-sent on
42
    # every write, and this value changes when a reader clicks a caret, not
43
    # when they load a page.
44
    if sections == Plug.Conn.get_session(conn, @session_key) do
45
      conn
46
    else
47
      Plug.Conn.put_session(conn, @session_key, sections)
48
    end
49
  end
50
51
  @doc "The sections map held in a LiveView's session, or an empty map."
52
  @spec from_session(map()) :: %{optional(String.t()) => boolean()}
53
  def from_session(session) when is_map(session) do
54
    case session[@session_key] do
55
      state when is_map(state) -> state
56
      _absent_or_wrong_shape -> %{}
57
    end
58
  end
59
60
  def from_session(_session), do: %{}
61
62
  defp parse(nil), do: %{}
63
64
  defp parse(value) when byte_size(value) > @maximum_bytes, do: %{}
65
66
  defp parse(value) do
67
    with {:ok, decoded} <- URI.decode(value) |> Jason.decode(),
68
         true <- is_map(decoded) do
69
      decoded
70
      |> Enum.filter(&admissible?/1)
71
      |> Enum.take(@maximum_sections)
72
      |> Map.new()
73
    else
74
      _unusable -> %{}
75
    end
76
  rescue
77
    # `URI.decode/1` raises on a malformed percent sequence, which a hand-edited
78
    # cookie can carry.
79
    ArgumentError -> %{}
80
  end
81
82
  defp admissible?({key, value}) when is_binary(key) and is_boolean(value),
83
    do: Regex.match?(@id_pattern, key)
84
85
  defp admissible?(_pair), do: false
86
end
lib/openagents_web/router.ex modified +1

@@ -20,6 +20,7 @@ defmodule OpenAgentsWeb.Router do

20 20
    plug OpenAgentsWeb.Plugs.ContentSecurityPolicy
21 21
22 22
    plug :fetch_current_user
23
    plug OpenAgentsWeb.Plugs.SidebarSections
23 24
  end
24 25
25 26
  pipeline :api do
lib/openagents_web/user_auth.ex modified +14

@@ -47,6 +47,16 @@ defmodule OpenAgentsWeb.UserAuth do

47 47
    end
48 48
  end
49 49
50
  # Which sections the reader has collapsed, so the first paint already agrees
51
  # with them. See `OpenAgentsWeb.Plugs.SidebarSections`.
52
  defp assign_sidebar_sections(socket, session) do
53
    Phoenix.Component.assign(
54
      socket,
55
      :sidebar_sections,
56
      OpenAgentsWeb.Plugs.SidebarSections.from_session(session)
57
    )
58
  end
59
50 60
  # The scope is the user, plus the answers the layout needs on every render
51 61
  # and must not re-ask for. Resolved once here, at mount.
52 62
  defp scope(user) do

@@ -54,6 +64,8 @@ defmodule OpenAgentsWeb.UserAuth do

54 64
  end
55 65
56 66
  def on_mount(:mount_current_user, _params, session, socket) do
67
    socket = assign_sidebar_sections(socket, session)
68
57 69
    with user_id when is_binary(user_id) <- session[@session_key],
58 70
         {:ok, user} <- Accounts.get_active_user(user_id) do
59 71
      {:cont,

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

70 82
  end
71 83
72 84
  def on_mount(:ensure_authenticated, _params, session, socket) do
85
    socket = assign_sidebar_sections(socket, session)
86
73 87
    with user_id when is_binary(user_id) <- session[@session_key],
74 88
         {:ok, user} <- Accounts.get_active_user(user_id) do
75 89
      {:cont,
test/openagents_web/sidebar_state_test.exs added +164

@@ -0,0 +1,164 @@

1
defmodule OpenAgentsWeb.SidebarStateTest do
2
  @moduledoc """
3
  Two sidebar facts that are invisible when they break.
4
5
  The first is the Admin row. `sidebar_footer/1` used to default its user to
6
  `nil`, which made a forgotten attribute indistinguishable from a signed-out
7
  visitor -- and the docs and component-library layouts both forgot it, so an
8
  operator browsing either surface silently had no Admin row. It is a required
9
  attribute now, and the test below is the behavioural half of that.
10
11
  The second is which sections the reader has collapsed. That has to reach the
12
  server, because several sidebar destinations live in different live sessions
13
  and moving between them is a full page load: state the browser holds alone
14
  arrives a frame late, and the reader sees a collapsed section paint open and
15
  then shut. A page that renders the shell without passing the state gets that
16
  flicker back, and nothing else would say so.
17
  """
18
19
  use OpenAgentsWeb.ConnCase, async: true
20
21
  import Phoenix.LiveViewTest
22
23
  alias OpenAgentsWeb.Plugs.SidebarSections
24
25
  describe "the admin row" do
26
    test "an operator sees it on the application shell", %{conn: conn} do
27
      conn = log_in_admin_user(conn, "operator-shell")
28
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
29
30
      assert has_element?(view, ~s(#sidebar .sidebar-footer a[href="/admin"]))
31
    end
32
33
    test "an operator sees it on the docs surface", %{conn: conn} do
34
      conn = log_in_admin_user(conn, "operator-docs")
35
      {:ok, view, _html} = live(conn, ~p"/docs")
36
37
      assert has_element?(view, ~s(.sidebar-footer a[href="/admin"]))
38
    end
39
40
    test "an operator sees it on the component library", %{conn: conn} do
41
      conn = log_in_admin_user(conn, "operator-components")
42
      {:ok, view, _html} = live(conn, ~p"/components")
43
44
      assert has_element?(view, ~s(.sidebar-footer a[href="/admin"]))
45
    end
46
47
    test "an ordinary account sees it on none of them", %{conn: conn} do
48
      conn = log_in_github_user(conn, "ordinary-account")
49
50
      for path <- [~p"/leaderboard", ~p"/docs", ~p"/components"] do
51
        {:ok, view, _html} = live(conn, path)
52
53
        refute has_element?(view, ~s(.sidebar-footer a[href="/admin"])),
54
               "admin row leaked on #{path}"
55
      end
56
    end
57
  end
58
59
  describe "collapsed sections survive the first paint" do
60
    test "a collapsed section renders collapsed, not open-then-corrected", %{conn: conn} do
61
      conn =
62
        conn
63
        |> log_in_chatting_user("collapse-first-paint")
64
        |> put_req_cookie("sidebar_sections", ~s({"sidebar-section-sarah":false}))
65
66
      {:ok, _view, html} = live(conn, ~p"/leaderboard")
67
68
      # `open` absent is the whole point: present would mean the browser paints
69
      # it expanded and a hook shuts it a frame later, which is the flicker.
70
      assert html =~ ~s(id="sidebar-section-sarah")
71
      refute html =~ ~r/id="sidebar-section-sarah"[^>]*\sopen/
72
    end
73
74
    test "with no cookie the seed governs, so the section is open", %{conn: conn} do
75
      conn = log_in_chatting_user(conn, "no-cookie-seed")
76
      {:ok, _view, html} = live(conn, ~p"/leaderboard")
77
78
      assert html =~ ~r/id="sidebar-section-sarah"[^>]*\sopen/
79
    end
80
81
    test "an expanded section stays expanded", %{conn: conn} do
82
      conn =
83
        conn
84
        |> log_in_chatting_user("expanded-stays")
85
        |> put_req_cookie("sidebar_sections", ~s({"sidebar-section-sarah":true}))
86
87
      {:ok, _view, html} = live(conn, ~p"/leaderboard")
88
89
      assert html =~ ~r/id="sidebar-section-sarah"[^>]*\sopen/
90
    end
91
  end
92
93
  describe "the cookie is reader-supplied, so it is bounded" do
94
    test "junk, wrong shapes, and unknown keys are dropped" do
95
      for value <- [
96
            "not json",
97
            "[1,2,3]",
98
            ~s({"sidebar-section-sarah":"yes"}),
99
            ~s({"../../etc/passwd":true}),
100
            ~s({"<script>":true}),
101
            ~s({"SIDEBAR-SECTION-SARAH":true}),
102
            "%E0%A4%A",
103
            String.duplicate("x", 4_000)
104
          ] do
105
        assert parse(value) == %{}, "admitted #{inspect(value)}"
106
      end
107
    end
108
109
    test "a well-formed entry survives" do
110
      assert parse(~s({"sidebar-section-sarah":false})) == %{"sidebar-section-sarah" => false}
111
    end
112
113
    test "a mixed payload keeps only the admissible entries" do
114
      assert parse(~s({"sidebar-section-sarah":false,"nope":true})) ==
115
               %{"sidebar-section-sarah" => false}
116
    end
117
118
    test "the number of sections is capped" do
119
      payload =
120
        1..200
121
        |> Map.new(fn n -> {"sidebar-section-s#{n}", false} end)
122
        |> Jason.encode!()
123
124
      parsed = parse(payload)
125
      assert map_size(parsed) <= 64
126
    end
127
  end
128
129
  describe "every shell call site passes the state" do
130
    # A page that renders the shell without it gets the flicker back, silently.
131
    # Checked at the source rather than by rendering, because the failure is a
132
    # missing attribute rather than a wrong output.
133
    test "no `<Layouts.app` omits sidebar_sections" do
134
      offenders =
135
        for path <- Path.wildcard("lib/openagents_web/**/*.{ex,heex}"),
136
            source = File.read!(path),
137
            [call] <- Regex.scan(~r/<Layouts\.app\b[^>]*>/s, source),
138
            not String.contains?(call, "sidebar_sections="),
139
            do: {path, String.slice(call, 0, 90)}
140
141
      assert offenders == [], """
142
      These render the application shell without passing the reader's collapsed
143
      sections, so a full page load paints their sections open and a hook shuts
144
      them a frame later:
145
146
      #{Enum.map_join(offenders, "\n", fn {file, call} -> "  #{file}\n    #{call}" end)}
147
148
      Add `sidebar_sections={assigns[:sidebar_sections]}`.
149
      """
150
    end
151
  end
152
153
  defp parse(value) do
154
    :get
155
    |> Plug.Test.conn("/")
156
    |> Plug.Test.put_req_cookie("sidebar_sections", value)
157
    |> Plug.Session.call(
158
      Plug.Session.init(store: :cookie, key: "_test", signing_salt: "salt", encryption_salt: "e")
159
    )
160
    |> Plug.Conn.fetch_session()
161
    |> SidebarSections.call([])
162
    |> Plug.Conn.get_session("sidebar_sections")
163
  end
164
end

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