Grandfather the agent's sidebar section, and stop navigation reopening sections

6cf546b580a4 · AtlantisPleb · · parent c786e238e8bf

Grandfather the agent's sidebar section, and stop navigation reopening sections

Her surfaces are grandfathered rather than launched: an account that has
already written to her keeps Chat, Computers and Memory, an operator always
has them, and a new account does not see the section at all.

"Has any message" is the wrong question. Every conversation is created with a
greeting already in it, so that is true the instant chat is opened and would
grandfather everyone. `user_has_messages?/1` asks for a message the *person*
wrote. There is a test that fails on the naive version specifically.

The answer is resolved once, by the mount that builds the scope, and carried
as a virtual field. The sidebar asks on every render -- per token on chat --
so it must stay a field read and never become a query. Writing the first
message grants it in place, so the section appears without a reload.

Also fixes a separate bug the section exposed: navigating between pages
reopened sections the reader had collapsed. The hook forced open any section
whose server-rendered `open` was true, which cannot distinguish "contains the
page you are on" from "open by default". It now asks the DOM, where the active
row already marks itself `aria-current`, so the reader's choice survives
navigation and a page still can never be hidden inside a closed section.

Alongside: halve the hero glow. Strength is now two multiplied dials, because
the light theme's rule outranks a placement's and a plain `opacity` override
would have silently done nothing in light mode.

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/accounts/user.ex
  • modified lib/openagents/conversations.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified lib/openagents_web/user_auth.ex
  • modified test/openagents/conversations_test.exs
  • added test/openagents_web/live/agent_surfaces_test.exs
  • modified test/openagents_web/live/changelog_live_test.exs
  • modified test/openagents_web/live/chat_live_test.exs
  • modified test/openagents_web/live/computers_navigation_test.exs
  • modified test/openagents_web/live/leaderboard_live_test.exs
  • modified test/support/conn_case.ex

Diff

15 files changed, +253 -26

assets/css/app.css modified +11

@@ -401,6 +401,17 @@

401 401
402 402
.sidebar-section {
403 403
  position: relative;
404
  /* A section heading needs air above it or it reads as one more row in the
405
     list it is naming. The nav lost its block padding when the sticky heading
406
     was fixed, and nothing replaced it, so "Sarah" sat hard against the row
407
     above. The space belongs to the section rather than the nav, which is why
408
     it survives a heading sticking to the scrollport edge. */
409
  margin-block-start: 16px;
410
}
411
412
/* A section that opens the rail has nothing above it to be held off from. */
413
.sidebar-section:first-child {
414
  margin-block-start: 0;
404 415
}
405 416
406 417
/* Only an open section needs holding off the next one. Paying that gap while
assets/css/openagents.css modified +19 -5

@@ -3344,6 +3344,12 @@

3344 3344
    left: 0;
3345 3345
    width: 100%;
3346 3346
    pointer-events: none;
3347
    /* Two independent dials, multiplied, so neither has to know the other's
3348
       value: `--glow-base` is what the theme allows, `--glow-scale` is what a
3349
       given placement asks for. A single `opacity` cannot do this -- the light
3350
       theme's rule outranks a placement's, so dimming one glow would silently
3351
       do nothing in light mode. */
3352
    opacity: calc(var(--glow-base, 1) * var(--glow-scale, 1));
3347 3353
  }
3348 3354
3349 3355
  .glow--top { top: 0; }

@@ -3391,7 +3397,7 @@

3391 3397
     backdrop on a dark surface reads as a stain on a white one. */
3392 3398
  :root[data-theme="light"] .glow,
3393 3399
  :root:not([data-theme="dark"]) .glow {
3394
    opacity: 0.35;
3400
    --glow-base: 0.35;
3395 3401
  }
3396 3402
3397 3403
  @media (min-width: 640px) {

@@ -4883,6 +4889,9 @@

4883 4889
  .hero__glow {
4884 4890
    top: 38%;
4885 4891
    z-index: 0;
4892
    /* Half strength. At full it lit the headline from behind hard enough to
4893
       read as a light source in the page rather than depth behind it. */
4894
    --glow-scale: 0.5;
4886 4895
  }
4887 4896
4888 4897
  /* Two edges of one line. The copyright says who owns this and the note says

@@ -6201,19 +6210,24 @@

6201 6210
6202 6211
@layer components {
6203 6212
  /* A project row plus its own destructive control. The row fills the width and
6204
     the control sits outside it, so clicking the row can only ever navigate. */
6205
  .project-list__row {
6213
     the control sits outside it, so clicking the row can only ever navigate.
6214
6215
     Named for the index, not for "project list": the dashboard already had a
6216
     `.project-list__row`, and this rule's `> :first-child { flex: 1 }` landed
6217
     on that row's leading icon and stretched it, pushing the title into the
6218
     middle of an empty row. Two different rows had the same name. */
6219
  .project-index__row {
6206 6220
    display: flex;
6207 6221
    align-items: center;
6208 6222
    gap: 8px;
6209 6223
  }
6210 6224
6211
  .project-list__row > :first-child {
6225
  .project-index__row > :first-child {
6212 6226
    min-width: 0;
6213 6227
    flex: 1;
6214 6228
  }
6215 6229
6216
  .project-list__delete {
6230
  .project-index__delete {
6217 6231
    flex: none;
6218 6232
  }
6219 6233
}
lib/openagents/accounts/user.ex modified +7

@@ -25,6 +25,12 @@ defmodule OpenAgents.Accounts.User do

25 25
    field :public_leaderboard_opted_out, :boolean, default: false
26 26
    field :browser_key_hash, :binary
27 27
28
    # Not a column: resolved once by `UserAuth.on_mount/4` when it builds the
29
    # scope, because the sidebar asks on every render and the answer must not
30
    # be a query each time. Defaults to false, so a user loaded by any other
31
    # path is treated as new rather than accidentally grandfathered.
32
    field :agent_surfaces?, :boolean, virtual: true, default: false
33
28 34
    has_one :storage_owner, OpenAgents.Conversations.Visitor
29 35
30 36
    timestamps()

@@ -47,6 +53,7 @@ defmodule OpenAgents.Accounts.User do

47 53
          github_token_rotated_at: DateTime.t() | nil,
48 54
          public_leaderboard_opted_out: boolean(),
49 55
          browser_key_hash: binary() | nil,
56
          agent_surfaces?: boolean(),
50 57
          inserted_at: DateTime.t(),
51 58
          updated_at: DateTime.t()
52 59
        }
lib/openagents/conversations.ex modified +26

@@ -48,6 +48,32 @@ defmodule OpenAgents.Conversations do

48 48
    end)
49 49
  end
50 50
51
  @doc """
52
  Whether this account has ever sent a message to the agent.
53
54
  Asks for a message the *person* wrote. Every conversation is created with an
55
  assistant greeting already in it, so "has any message" is true for an account
56
  that has never opened chat, and would grandfather everyone.
57
58
  Existence only, and bounded by `limit: 1`: this runs on every page render to
59
  decide whether the sidebar shows the agent's surfaces at all.
60
  """
61
  @spec user_has_messages?(User.t() | nil) :: boolean()
62
  def user_has_messages?(nil), do: false
63
64
  def user_has_messages?(%User{id: user_id}) do
65
    from(m in Message,
66
      join: c in Conversation,
67
      on: c.id == m.conversation_id,
68
      join: v in assoc(c, :visitor),
69
      where: v.user_id == ^user_id and m.role == "user",
70
      select: 1,
71
      limit: 1
72
    )
73
    |> Repo.one()
74
    |> is_integer()
75
  end
76
51 77
  def get_conversation_for_user(%User{id: user_id}) do
52 78
    from(c in Conversation,
53 79
      join: v in assoc(c, :visitor),
lib/openagents_web/components/layouts.ex modified +27 -11

@@ -252,7 +252,8 @@ defmodule OpenAgentsWeb.Layouts do

252 252
  the section you are reading would hide your own location.
253 253
  """
254 254
  attr :title, :string, required: true
255
  attr :open, :boolean, default: false
255
  attr :open, :boolean, default: false, doc: "first paint only; the reader's choice wins after"
256
256 257
  attr :id, :string, default: nil, doc: "defaults to a slug of the title"
257 258
  slot :inner_block, required: true
258 259

@@ -269,7 +270,6 @@ defmodule OpenAgentsWeb.Layouts do

269 270
      class="docs-sidebar__section sidebar-section"
270 271
      open={@open}
271 272
      phx-hook=".SidebarSection"
272
      data-holds-active={to_string(@open)}
273 273
    >
274 274
      <summary class="sidebar-section-label sidebar-section__summary">
275 275
        <UI.icon name="chevron-right" class="sidebar-section__caret" />

@@ -279,13 +279,14 @@ defmodule OpenAgentsWeb.Layouts do

279 279
        {render_slot(@inner_block)}
280 280
      </div>
281 281
    </details>
282
    <%!-- `open` above is a seed, not the truth. The server can only derive it
283
    from the active page, so re-deriving it on every navigation collapses every
284
    section the reader opened but is not currently inside. The hook makes the
285
    reader's own choices the state and keeps them in sessionStorage; the server
286
    still decides on first paint and whenever a section holds the active page,
287
    so a page can never be hidden inside a section the reader had closed, and
288
    the sidebar works with no JavaScript at all. --%>
282
    <%!-- `open` above is a seed, not the truth. Re-applying it on every
283
    navigation collapses every section the reader opened but is not currently
284
    inside, and re-opens every section they closed that happens to be open by
285
    default. The hook makes the reader's own choices the state and keeps them
286
    in sessionStorage; the seed decides first paint, and a section containing
287
    the active row still opens regardless, so a page can never be hidden inside
288
    a section the reader had closed. With no JavaScript the seed governs and
289
    the sidebar still works. --%>
289 290
    <script :type={Phoenix.LiveView.ColocatedHook} name=".SidebarSection">
290 291
      const KEY = "sidebar-sections"
291 292

@@ -337,7 +338,12 @@ defmodule OpenAgentsWeb.Layouts do

337 338
        restore() {
338 339
          // A section holding the active page opens regardless of what the
339 340
          // reader last did, so navigation can never land on a hidden row.
340
          if (this.el.dataset.holdsActive === "true") {
341
          // Asked of the DOM rather than of the server: the active row already
342
          // marks itself `aria-current`, and a server-side answer has to be
343
          // derived from the section's `open` attribute, which is true for a
344
          // section that is merely open by default -- so every navigation
345
          // re-forced such a section open and undid the reader's collapse.
346
          if (this.el.querySelector("[aria-current]")) {
341 347
            this.el.open = true
342 348
            this.onToggle()
343 349
            return

@@ -540,6 +546,8 @@ defmodule OpenAgentsWeb.Layouts do

540 546
  slot :extra, doc: "rows contributed by the current page"
541 547
542 548
  defp sidebar(assigns) do
549
    assigns = assign(assigns, :agent_surfaces?, agent_surfaces?(assigns[:current_scope]))
550
543 551
    ~H"""
544 552
    <aside id="sidebar" class="sidebar hidden lg:flex">
545 553
      <Layouts.sidebar_brand />

@@ -565,7 +573,7 @@ defmodule OpenAgentsWeb.Layouts do

565 573
      the two things it can reach -- and reading as a group says that in a way
566 574
      six flat rows cannot. Open by default: grouping is for orientation here,
567 575
      not for hiding. --%>
568
      <Layouts.sidebar_section title="Sarah" open>
576
      <Layouts.sidebar_section :if={@agent_surfaces?} title="Sarah" open>
569 577
        <Layouts.sidebar_link path={~p"/chat"} label="Chat" icon="chat" patchable={false} />
570 578
        <Layouts.sidebar_link
571 579
          path={~p"/computers"}

@@ -590,6 +598,14 @@ defmodule OpenAgentsWeb.Layouts do

590 598
    """
591 599
  end
592 600
601
  # The agent's surfaces are grandfathered, not launched: an account that has
602
  # already talked to her keeps them, and an operator always has them, but a
603
  # new account never sees them. `agent_surfaces?` is resolved once by
604
  # `UserAuth.on_mount/4`; this runs on every render, so it must stay a field
605
  # read and never become a query.
606
  defp agent_surfaces?(nil), do: false
607
  defp agent_surfaces?(user), do: user.agent_surfaces? or admin?(user)
608
593 609
  # A nil scope is not an operator. The footer renders on public pages too.
594 610
  defp admin?(nil), do: false
595 611
  defp admin?(user), do: OpenAgents.Accounts.admin?(user)
lib/openagents_web/live/chat_live.ex modified +12

@@ -382,6 +382,7 @@ defmodule OpenAgentsWeb.ChatLive do

382 382
          |> assign(:tool_activity, [])
383 383
          |> assign(:composer_error, nil)
384 384
          |> assign(:form, composer_form())
385
          |> grant_agent_surfaces()
385 386
          |> push_event("composer:clear", %{})
386 387
          |> push_event("chat:scroll-bottom", %{})
387 388

@@ -392,6 +393,17 @@ defmodule OpenAgentsWeb.ChatLive do

392 393
    end
393 394
  end
394 395
396
  # Writing to her is the act that earns the sidebar section. The scope is
397
  # resolved once at mount, so without this the person who just sent their
398
  # first message would not see the section until their next page load.
399
  defp grant_agent_surfaces(
400
         %{assigns: %{current_scope: %{agent_surfaces?: false} = scope}} = socket
401
       ) do
402
    Phoenix.Component.assign(socket, :current_scope, %{scope | agent_surfaces?: true})
403
  end
404
405
  defp grant_agent_surfaces(socket), do: socket
406
395 407
  @maximum_queued_messages 10
396 408
397 409
  # Append a message to run after the active turn(s), so the composer never
lib/openagents_web/live/project_index_live.ex modified +3 -3

@@ -70,8 +70,8 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

70 70
        <section>No projects yet.</section>
71 71
      </div>
72 72
73
      <div :if={@projects != []} class="project-list">
74
        <div :for={project <- @projects} class="project-list__row">
73
      <div :if={@projects != []} class="project-index">
74
        <div :for={project <- @projects} class="project-index__row">
75 75
          <Circle.project_row
76 76
            name={project.title}
77 77
            navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}

@@ -82,7 +82,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

82 82
          row grew: the row is a link to a board, and a destructive action
83 83
          inside a link target is how people delete things by accident. --%>
84 84
          <button
85
            class="btn project-list__delete"
85
            class="btn project-index__delete"
86 86
            data-variant="ghost"
87 87
            data-size="sm"
88 88
            data-tone="danger"
lib/openagents_web/user_auth.ex modified +8 -2

@@ -47,13 +47,19 @@ defmodule OpenAgentsWeb.UserAuth do

47 47
    end
48 48
  end
49 49
50
  # The scope is the user, plus the answers the layout needs on every render
51
  # and must not re-ask for. Resolved once here, at mount.
52
  defp scope(user) do
53
    %{user | agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user)}
54
  end
55
50 56
  def on_mount(:mount_current_user, _params, session, socket) do
51 57
    with user_id when is_binary(user_id) <- session[@session_key],
52 58
         {:ok, user} <- Accounts.get_active_user(user_id) do
53 59
      {:cont,
54 60
       socket
55 61
       |> Phoenix.Component.assign(:current_user, user)
56
       |> Phoenix.Component.assign(:current_scope, user)}
62
       |> Phoenix.Component.assign(:current_scope, scope(user))}
57 63
    else
58 64
      _missing_or_inactive ->
59 65
        {:cont,

@@ -69,7 +75,7 @@ defmodule OpenAgentsWeb.UserAuth do

69 75
      {:cont,
70 76
       socket
71 77
       |> Phoenix.Component.assign(:current_user, user)
72
       |> Phoenix.Component.assign(:current_scope, user)
78
       |> Phoenix.Component.assign(:current_scope, scope(user))
73 79
       |> Phoenix.LiveView.attach_hook(
74 80
         :active_user_guard,
75 81
         :handle_event,
test/openagents/conversations_test.exs modified +46

@@ -146,4 +146,50 @@ defmodule OpenAgents.ConversationsTest do

146 146
    assert length(older) == 6
147 147
    assert MapSet.disjoint?(MapSet.new(recent, & &1.id), MapSet.new(older, & &1.id))
148 148
  end
149
150
  describe "user_has_messages?/1" do
151
    # This decides whether the agent's surfaces appear in the sidebar at all:
152
    # an account that has already talked to her keeps them, a new one never
153
    # sees them. Getting it wrong in the permissive direction is invisible --
154
    # the section simply shows for everybody -- so the greeting case below is
155
    # the one that matters.
156
157
    test "an account that has never opened chat has not sent a message" do
158
      user = OpenAgents.AccountsFixtures.repository_user_fixture("never-chatted")
159
      refute Conversations.user_has_messages?(user)
160
    end
161
162
    test "the seeded greeting does not count as the person having written" do
163
      # Every conversation is created with an assistant greeting already in it,
164
      # so "does this account have any message" is true the instant chat is
165
      # opened -- which would grandfather every account that ever loaded the
166
      # page. The question has to be about a message the person wrote.
167
      user = OpenAgents.AccountsFixtures.repository_user_fixture("only-greeted")
168
      assert {:ok, conversation} = Conversations.ensure_conversation(user)
169
170
      {[%Message{role: "assistant"}], false} = Conversations.list_messages(conversation)
171
      refute Conversations.user_has_messages?(user)
172
    end
173
174
    test "an account that has written is grandfathered" do
175
      user = OpenAgents.AccountsFixtures.repository_user_fixture("has-chatted")
176
      assert {:ok, conversation} = Conversations.ensure_conversation(user)
177
      assert {:ok, _records} = Conversations.create_turn(conversation, "Hello.")
178
179
      assert Conversations.user_has_messages?(user)
180
    end
181
182
    test "one account's messages do not grandfather another" do
183
      talker = OpenAgents.AccountsFixtures.repository_user_fixture("talker")
184
      bystander = OpenAgents.AccountsFixtures.repository_user_fixture("bystander")
185
      assert {:ok, conversation} = Conversations.ensure_conversation(talker)
186
      assert {:ok, _records} = Conversations.create_turn(conversation, "Hello.")
187
188
      refute Conversations.user_has_messages?(bystander)
189
    end
190
191
    test "no account at all has no messages" do
192
      refute Conversations.user_has_messages?(nil)
193
    end
194
  end
149 195
end
test/openagents_web/live/agent_surfaces_test.exs added +73

@@ -0,0 +1,73 @@

1
defmodule OpenAgentsWeb.AgentSurfacesTest do
2
  @moduledoc """
3
  The agent's sidebar section is grandfathered, not launched.
4
5
  An account that has already talked to her keeps her surfaces, and an operator
6
  always has them. A new account does not, and will not until it writes to her.
7
8
  These are worth holding because the failure is silent in the direction that
9
  matters: if the check goes permissive -- and the obvious implementation does,
10
  since every conversation is created with a greeting message already in it --
11
  the section simply appears for everybody and nothing anywhere complains.
12
  """
13
14
  use OpenAgentsWeb.ConnCase
15
  import Phoenix.LiveViewTest
16
17
  @section "#sidebar #sidebar-section-sarah"
18
19
  describe "who sees her" do
20
    test "a new account does not", %{conn: conn} do
21
      conn = log_in_github_user(conn, "brand-new-account")
22
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
23
24
      refute has_element?(view, @section)
25
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
26
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
27
      refute has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
28
    end
29
30
    test "an account that has written to her does", %{conn: conn} do
31
      conn = log_in_chatting_user(conn, "already-talked")
32
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
33
34
      assert has_element?(view, @section)
35
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/chat"]))
36
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/memory"]))
37
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/computers"]))
38
    end
39
40
    test "an operator does, without having written", %{conn: conn} do
41
      conn = log_in_admin_user(conn, "operator-who-never-chatted")
42
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
43
44
      assert has_element?(view, @section)
45
    end
46
47
    test "the rest of the sidebar is unaffected either way", %{conn: conn} do
48
      # The gate hides one section, not the application. A new account still
49
      # gets the shared destinations and the footer.
50
      conn = log_in_github_user(conn, "new-account-still-has-a-sidebar")
51
      {:ok, view, _html} = live(conn, ~p"/leaderboard")
52
53
      assert has_element?(view, ~s(#sidebar a.sidebar-row__hit[href="/"]))
54
      assert has_element?(view, ~s(#sidebar .sidebar-footer a[href="/docs"]))
55
      assert has_element?(view, "#account-bar-trigger")
56
    end
57
  end
58
59
  describe "earning her" do
60
    test "the first message reveals the section without a reload", %{conn: conn} do
61
      conn = log_in_github_user(conn, "first-message-account")
62
      {:ok, view, _html} = live(conn, ~p"/chat")
63
64
      refute has_element?(view, @section)
65
66
      view
67
      |> form("#message-form", chat: %{message: "Hello."})
68
      |> render_submit()
69
70
      assert has_element?(view, @section)
71
    end
72
  end
73
end
test/openagents_web/live/changelog_live_test.exs modified +1 -1

@@ -56,7 +56,7 @@ defmodule OpenAgentsWeb.ChangelogLiveTest do

56 56
  end
57 57
58 58
  test "carries the shell sidebar when logged in", %{conn: conn} do
59
    conn = log_in_github_user(conn, "changelog-header-browser")
59
    conn = log_in_chatting_user(conn, "changelog-header-browser")
60 60
61 61
    {:ok, _view, html} = live(conn, ~p"/changelog")
62 62
test/openagents_web/live/chat_live_test.exs modified +3 -2

@@ -52,8 +52,9 @@ defmodule OpenAgentsWeb.ChatLiveTest do

52 52
           end)
53 53
  end
54 54
55
  test "the sidebar carries the shared destinations for everyone", %{conn: conn} do
56
    conn = log_in_github_user(conn, "computers-nav-browser")
55
  test "the sidebar carries the shared destinations for an account that has written",
56
       %{conn: conn} do
57
    conn = log_in_chatting_user(conn, "computers-nav-browser")
57 58
    {:ok, view, _html} = live(conn, ~p"/chat")
58 59
59 60
    # Computers, Memory and Leaderboard are destinations for everyone, so they
test/openagents_web/live/computers_navigation_test.exs modified +1 -1

@@ -3,7 +3,7 @@ defmodule OpenAgentsWeb.ComputersNavigationTest do

3 3
  import Phoenix.LiveViewTest
4 4
5 5
  test "the canonical Computers surface carries authenticated application chrome", %{conn: conn} do
6
    conn = log_in_github_user(conn, "computers-surface")
6
    conn = log_in_chatting_user(conn, "computers-surface")
7 7
    {:ok, view, _html} = live(conn, ~p"/computers")
8 8
9 9
    assert has_element?(view, "#computers-page")
test/openagents_web/live/leaderboard_live_test.exs modified +1 -1

@@ -48,7 +48,7 @@ defmodule OpenAgentsWeb.LeaderboardLiveTest do

48 48
  end
49 49
50 50
  test "carries the shell account menu and sidebar when logged in", %{conn: conn} do
51
    conn = log_in_github_user(conn, "leaderboard-header-browser")
51
    conn = log_in_chatting_user(conn, "leaderboard-header-browser")
52 52
53 53
    {:ok, _view, html} = live(conn, ~p"/leaderboard")
54 54
test/support/conn_case.ex modified +15

@@ -64,6 +64,21 @@ defmodule OpenAgentsWeb.ConnCase do

64 64
    Plug.Test.init_test_session(conn, %{"user_id" => user.id})
65 65
  end
66 66
67
  @doc """
68
  Logs in an account that has already written to the agent.
69
70
  Her surfaces are grandfathered rather than launched, so an account that has
71
  never sent a message does not see them. A test about the sidebar's shared
72
  destinations wants an account that does.
73
  """
74
  def log_in_chatting_user(conn, key) when is_binary(key) do
75
    user = github_user(key)
76
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
77
    {:ok, _records} = OpenAgents.Conversations.create_turn(conversation, "Hello.")
78
79
    Plug.Test.init_test_session(conn, %{"user_id" => user.id})
80
  end
81
67 82
  def put_forge_api_token(conn, key) when is_binary(key) do
68 83
    user = github_user("api-token-" <> key)
69 84

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