Map a pull request stack as one visual object

40c292246811 · Devin AI · · parent a8a0ebf661df

Map a pull request stack as one visual object

Add UI.stack_map/1 to the component library: one stack as an ordered
rail, layers top-first down to the trunk, each layer wearing its pull
request state as a coloured glyph. The viewed layer is washed and marked
aria-current="page" rather than linked, matching breadcrumb/1. The
pull request page renders the map inside the stacked review section, and
the component catalog carries a demo with an add-to-stack row and an
unstack action slot.

Co-Authored-By: Christopher David <chris@openagents.com>
Co-Authored-By
Christopher David <chris@openagents.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/openagents.css
  • modified lib/openagents_web/component_catalog.ex
  • modified lib/openagents_web/components/ui.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/pull_request_show_live.ex
  • modified test/openagents_web/ui_test.exs

Diff

6 files changed, +500 -0

assets/css/openagents.css modified +196

@@ -5291,6 +5291,202 @@

5291 5291
    font-weight: 500;
5292 5292
  }
5293 5293
5294
  /* ── Stack map ──────────────────────────────────────────────────────────── */
5295
5296
  /* One pull request stack as an ordered rail: layers top-first, the trunk at
5297
     the bottom, and a hairline connector threading through the state glyphs so
5298
     the chain reads as one object. The current layer is washed rather than
5299
     underlined -- the reader is inside it, not travelling to it. */
5300
  .stack-map {
5301
    overflow: hidden;
5302
    border: 1px solid var(--line);
5303
    border-radius: var(--radius-lg);
5304
    background: var(--ink-surface);
5305
  }
5306
5307
  .stack-map__header {
5308
    display: flex;
5309
    align-items: center;
5310
    gap: 10px;
5311
    border-block-end: 1px solid var(--line);
5312
    padding: 12px 16px;
5313
  }
5314
5315
  .stack-map__title {
5316
    color: var(--text-primary);
5317
    font-weight: 600;
5318
    font-size: 0.9375rem;
5319
  }
5320
5321
  .stack-map__count {
5322
    color: var(--text-muted);
5323
    font-size: 0.75rem;
5324
    font-variant-numeric: tabular-nums;
5325
  }
5326
5327
  .stack-map__actions {
5328
    display: inline-flex;
5329
    align-items: center;
5330
    gap: 4px;
5331
    margin-inline-start: auto;
5332
  }
5333
5334
  .stack-map__layers {
5335
    position: relative;
5336
    margin: 0;
5337
    padding: 8px 0;
5338
    list-style: none;
5339
  }
5340
5341
  /* The connector rail. It threads behind the state glyphs, which sit on the
5342
     surface colour so the line appears to pass through them. */
5343
  .stack-map__layers::before {
5344
    position: absolute;
5345
    inset-block: 14px;
5346
    inset-inline-start: 23px;
5347
    border-inline-start: 1px solid var(--line-strong);
5348
    content: "";
5349
  }
5350
5351
  .stack-map__add,
5352
  .stack-map__trunk {
5353
    position: relative;
5354
    display: flex;
5355
    align-items: center;
5356
    gap: 10px;
5357
    padding: 8px 16px;
5358
  }
5359
5360
  .stack-map__add-link {
5361
    display: inline-flex;
5362
    align-items: center;
5363
    gap: 10px;
5364
    color: var(--text-muted);
5365
    font-size: 0.875rem;
5366
    text-decoration: none;
5367
    transition: color var(--motion-fast) var(--ease);
5368
  }
5369
5370
  @media (hover: hover) {
5371
    .stack-map__add-link:hover {
5372
      color: var(--text-primary);
5373
    }
5374
  }
5375
5376
  .stack-map__add-icon {
5377
    z-index: 1;
5378
    box-sizing: content-box;
5379
    width: 16px;
5380
    height: 16px;
5381
    flex: none;
5382
    padding: 0;
5383
    background: var(--ink-surface);
5384
    color: var(--icon-tertiary);
5385
    font-size: 16px;
5386
  }
5387
5388
  .stack-map__layer {
5389
    position: relative;
5390
    display: grid;
5391
    grid-template-columns: 16px 1fr;
5392
    align-items: center;
5393
    gap: 4px 10px;
5394
    padding: 8px 16px;
5395
  }
5396
5397
  .stack-map__layer[aria-current="page"] {
5398
    background: var(--wash-selected);
5399
  }
5400
5401
  .stack-map__layer[aria-current="page"]::before {
5402
    position: absolute;
5403
    inset-block: 0;
5404
    inset-inline-start: 0;
5405
    border-inline-start: 2px solid var(--accent-bright);
5406
    content: "";
5407
  }
5408
5409
  .stack-map__state {
5410
    z-index: 1;
5411
    grid-row: span 2;
5412
    align-self: start;
5413
    box-sizing: content-box;
5414
    width: 16px;
5415
    height: 16px;
5416
    flex: none;
5417
    padding-block: 2px;
5418
    background: var(--ink-surface);
5419
    color: var(--icon-tertiary);
5420
    font-size: 16px;
5421
  }
5422
5423
  .stack-map__layer[aria-current="page"] .stack-map__state {
5424
    background: transparent;
5425
  }
5426
5427
  .stack-map__layer[data-state="open"] .stack-map__state {
5428
    color: var(--success);
5429
  }
5430
5431
  .stack-map__layer[data-state="merged"] .stack-map__state {
5432
    color: var(--accent-bright);
5433
  }
5434
5435
  .stack-map__layer[data-state="closed"] .stack-map__state {
5436
    color: var(--danger);
5437
  }
5438
5439
  .stack-map__layer-link {
5440
    color: var(--text-primary);
5441
    font-weight: 600;
5442
    font-size: 0.875rem;
5443
    text-decoration: none;
5444
  }
5445
5446
  @media (hover: hover) {
5447
    a.stack-map__layer-link:hover {
5448
      color: var(--accent-bright);
5449
    }
5450
  }
5451
5452
  .stack-map__layer-meta {
5453
    display: inline-flex;
5454
    align-items: center;
5455
    gap: 8px;
5456
    grid-column: 2;
5457
    min-width: 0;
5458
  }
5459
5460
  .stack-map__layer-number {
5461
    color: var(--text-muted);
5462
    font-size: 0.8125rem;
5463
    font-variant-numeric: tabular-nums;
5464
  }
5465
5466
  .stack-map__branch {
5467
    overflow: hidden;
5468
    border-radius: 6px;
5469
    background: var(--wash-selected);
5470
    padding: 2px 8px;
5471
    color: var(--text-body);
5472
    font-family: var(--font-mono);
5473
    font-size: 0.75rem;
5474
    text-decoration: none;
5475
    text-overflow: ellipsis;
5476
    white-space: nowrap;
5477
  }
5478
5479
  @media (hover: hover) {
5480
    a.stack-map__branch:hover {
5481
      color: var(--accent-bright);
5482
    }
5483
  }
5484
5485
  .stack-map__trunk .stack-map__state {
5486
    grid-row: auto;
5487
    padding-block: 0;
5488
  }
5489
5294 5490
  /* ── File table ─────────────────────────────────────────────────────────── */
5295 5491
5296 5492
  .file-table {
lib/openagents_web/component_catalog.ex modified +7

@@ -207,6 +207,13 @@ defmodule OpenAgentsWeb.ComponentCatalog do

207 207
          source: "OpenAgentsWeb.UI.repo_view/1",
208 208
          summary: "The whole repository home: identity, sections, tree, and rail in one frame."
209 209
        },
210
        %{
211
          slug: "openagents-stack-map",
212
          title: "Stack map",
213
          icon: "stack",
214
          source: "OpenAgentsWeb.UI.stack_map/1",
215
          summary: "One pull request stack, layers top-first, down to the trunk it targets."
216
        },
210 217
        %{
211 218
          slug: "openagents-status-indicator",
212 219
          title: "Status indicator",
lib/openagents_web/components/ui.ex modified +100

@@ -863,6 +863,106 @@ defmodule OpenAgentsWeb.UI do

863 863
    """
864 864
  end
865 865
866
  @doc """
867
  The map of one pull request stack: every layer in order, top of the stack
868
  first, ending at the trunk the whole stack targets.
869
870
  Layers arrive top-first because that is how a stack reads — the newest work
871
  sits on top and the trunk anchors the bottom, the way the branches actually
872
  chain. Each layer carries its pull request state as a glyph beside the
873
  title, so a reader sees at a glance which layers are merged, open, draft,
874
  or closed.
875
876
  The layer for the page the reader is on renders as text with
877
  `aria-current="page"` rather than as a link, for the same reason
878
  `breadcrumb/1` does: a page linking to itself is a dead control that still
879
  looks live. Every other layer is a link to its pull request.
880
881
  The trunk row is a destination too when `trunk_navigate` or `trunk_href` is
882
  given — the branch the stack lands on is a real place — and plain text when
883
  it is not, as in the catalog where there is nowhere to send the reader.
884
  """
885
  attr :id, :string, required: true
886
  attr :number, :integer, required: true, doc: "the stack number, scoped to the repository"
887
  attr :trunk, :string, required: true, doc: "the branch the whole stack targets"
888
  attr :trunk_navigate, :string, default: nil
889
  attr :trunk_href, :string, default: nil
890
  attr :add_navigate, :string, default: nil, doc: "where a new layer on top of the stack begins"
891
  attr :add_href, :string, default: nil
892
  attr :class, :any, default: nil
893
894
  attr :layers, :list,
895
    required: true,
896
    doc:
897
      "`[%{title, number, branch, state}]` from top of the stack to bottom, " <>
898
        "each optionally carrying `navigate` or `href` for its destination and " <>
899
        "`current: true` for the layer being viewed; `state` is `open`, " <>
900
        "`merged`, `closed`, or `draft`"
901
902
  attr :rest, :global
903
904
  slot :action, doc: "controls at the trailing edge of the header, such as unstack"
905
906
  def stack_map(assigns) do
907
    ~H"""
908
    <section id={@id} class={["stack-map", @class]} aria-label={"Stack ##{@number}"} {@rest}>
909
      <header class="stack-map__header">
910
        <span class="stack-map__title">Stack #{@number}</span>
911
        <span class="stack-map__count">{layer_count_label(length(@layers))}</span>
912
        <span :if={@action != []} class="stack-map__actions">{render_slot(@action)}</span>
913
      </header>
914
      <ol class="stack-map__layers">
915
        <li :if={@add_navigate || @add_href} class="stack-map__add">
916
          <.link navigate={@add_navigate} href={@add_href} class="stack-map__add-link">
917
            <.icon name="plus" class="stack-map__add-icon" /> Add to stack
918
          </.link>
919
        </li>
920
        <li
921
          :for={layer <- @layers}
922
          class="stack-map__layer"
923
          data-state={layer.state}
924
          aria-current={layer[:current] && "page"}
925
        >
926
          <.icon name={pull_request_state_icon(layer.state)} class="stack-map__state" />
927
          <.link
928
            :if={!layer[:current]}
929
            navigate={layer[:navigate]}
930
            href={layer[:href]}
931
            class="stack-map__layer-link"
932
          >
933
            {layer.title}
934
          </.link>
935
          <span :if={layer[:current]} class="stack-map__layer-link">{layer.title}</span>
936
          <span class="stack-map__layer-meta">
937
            <span class="stack-map__layer-number">#{layer.number}</span>
938
            <span class="stack-map__branch">{layer.branch}</span>
939
          </span>
940
        </li>
941
        <li class="stack-map__trunk">
942
          <.icon name="branch" class="stack-map__state" />
943
          <.link
944
            :if={@trunk_navigate || @trunk_href}
945
            navigate={@trunk_navigate}
946
            href={@trunk_href}
947
            class="stack-map__branch"
948
          >
949
            {@trunk}
950
          </.link>
951
          <span :if={!(@trunk_navigate || @trunk_href)} class="stack-map__branch">{@trunk}</span>
952
        </li>
953
      </ol>
954
    </section>
955
    """
956
  end
957
958
  defp layer_count_label(1), do: "1 layer"
959
  defp layer_count_label(count), do: "#{count} layers"
960
961
  defp pull_request_state_icon("merged"), do: "pull-request-merged"
962
  defp pull_request_state_icon("closed"), do: "pull-request-closed"
963
  defp pull_request_state_icon("draft"), do: "pull-request-draft"
964
  defp pull_request_state_icon(_open), do: "pull-request-open"
965
866 966
  @doc """
867 967
  A repository's file table: the ref bar, the latest commit, and the entries.
868 968
lib/openagents_web/live/components_live.ex modified +60

@@ -1451,6 +1451,66 @@ defmodule OpenAgentsWeb.ComponentsLive do

1451 1451
    """
1452 1452
  end
1453 1453
1454
  defp component_demo(%{item: %{slug: "openagents-stack-map"}} = assigns) do
1455
    assigns =
1456
      assign(assigns, :layers, [
1457
        %{
1458
          title: "Add frontend",
1459
          number: 32,
1460
          branch: "frontend",
1461
          state: "draft",
1462
          href: "#pr-32"
1463
        },
1464
        %{
1465
          title: "Add API endpoints",
1466
          number: 30,
1467
          branch: "api-endpoints",
1468
          state: "open",
1469
          href: "#pr-30"
1470
        },
1471
        %{
1472
          title: "Add authentication layer",
1473
          number: 24,
1474
          branch: "auth-layer",
1475
          state: "open",
1476
          current: true
1477
        }
1478
      ])
1479
1480
    ~H"""
1481
    <div class="space-y-3">
1482
      <p class="text-sm text-base-content/60">
1483
        A stacked pull request's place in its stack, at a glance. Layers arrive
1484
        top-first and the trunk anchors the bottom, the way the branches actually
1485
        chain. Each layer wears its pull request state as a coloured glyph, and the
1486
        one the reader is viewing is washed and marked <code>aria-current="page"</code>
1487
        rather than linked — a page linking to itself is a dead control that still
1488
        looks live.
1489
      </p>
1490
      <div class="max-w-md">
1491
        <UI.stack_map
1492
          id="demo-stack-map"
1493
          number={31}
1494
          trunk="main"
1495
          trunk_href="#main"
1496
          add_href="#add"
1497
          layers={@layers}
1498
        >
1499
          <:action>
1500
            <UI.button variant={:ghost} size={:sm}>Unstack</UI.button>
1501
          </:action>
1502
        </UI.stack_map>
1503
      </div>
1504
      <p class="text-sm text-base-content/60">
1505
        The connector rail threads through the state glyphs so the chain reads as one
1506
        object, and the <strong>Add to stack</strong>
1507
        row sits above the top layer because that is where the next layer would go. The
1508
        header's action slot holds stack-level controls — here an unstack affordance.
1509
      </p>
1510
    </div>
1511
    """
1512
  end
1513
1454 1514
  defp component_demo(%{item: %{slug: "openagents-diff-file"}} = assigns) do
1455 1515
    assigns =
1456 1516
      assign(
lib/openagents_web/live/pull_request_show_live.ex modified +34

@@ -127,6 +127,14 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

127 127
                </.badge>
128 128
              </div>
129 129
130
              <.stack_map
131
                id="stack-map"
132
                number={@stack_context.stack.number}
133
                trunk={@stack_context.stack.trunk_ref}
134
                layers={stack_map_layers(@stack_context, @owner, @repo)}
135
                class="mt-4 max-w-md"
136
              />
137
130 138
              <nav class="mt-4 flex gap-2" aria-label="Stack diff views">
131 139
                <.button
132 140
                  id="stack-view-layer"

@@ -201,6 +209,32 @@ defmodule OpenAgentsWeb.PullRequestShowLive do

201 209
202 210
  defp short(sha), do: String.slice(sha, 0, 12)
203 211
212
  defp stack_map_layers(context, owner, repo) do
213
    context.stack.entries
214
    |> Enum.sort_by(& &1.position, :desc)
215
    |> Enum.map(fn entry ->
216
      pull_request = entry.pull_request
217
218
      %{
219
        title: pull_request.issue.title,
220
        number: pull_request.issue.number,
221
        branch: pull_request.head_ref,
222
        state: layer_state(pull_request),
223
        navigate: ~p"/#{owner}/#{repo}/pulls/#{pull_request.issue.number}",
224
        current: entry.id == context.entry.id
225
      }
226
    end)
227
  end
228
229
  defp layer_state(pull_request) do
230
    cond do
231
      pull_request.merged_at -> "merged"
232
      pull_request.state == "closed" -> "closed"
233
      pull_request.draft -> "draft"
234
      true -> "open"
235
    end
236
  end
237
204 238
  defp visible_repository!(owner, repo, user) do
205 239
    Repositories.get_visible_by_path!(owner, repo, user)
206 240
  rescue
test/openagents_web/ui_test.exs modified +103

@@ -497,6 +497,109 @@ defmodule OpenAgentsWeb.UITest do

497 497
    end
498 498
  end
499 499
500
  describe "stack_map/1" do
501
    defp demo_layers do
502
      [
503
        %{title: "Add frontend", number: 32, branch: "frontend", state: "draft", href: "#pr-32"},
504
        %{title: "Add API", number: 30, branch: "api", state: "merged", href: "#pr-30"},
505
        %{title: "Add auth", number: 24, branch: "auth-layer", state: "open", current: true}
506
      ]
507
    end
508
509
    test "renders layers top-first with the trunk anchoring the bottom" do
510
      assigns = %{layers: demo_layers()}
511
512
      html =
513
        rendered_to_string(
514
          ~H|<.stack_map id="stack-map" number={31} trunk="main" layers={@layers} />|
515
        )
516
517
      assert html =~ "Stack #31"
518
      assert html =~ "3 layers"
519
520
      frontend = :binary.match(html, "Add frontend") |> elem(0)
521
      api = :binary.match(html, "Add API") |> elem(0)
522
      auth = :binary.match(html, "Add auth") |> elem(0)
523
      trunk = :binary.match(html, ">main<") |> elem(0)
524
525
      assert frontend < api
526
      assert api < auth
527
      assert auth < trunk
528
    end
529
530
    test "marks the viewed layer aria-current and does not link it to itself" do
531
      assigns = %{layers: demo_layers()}
532
533
      html =
534
        rendered_to_string(
535
          ~H|<.stack_map id="stack-map" number={31} trunk="main" layers={@layers} />|
536
        )
537
538
      assert html =~ ~s(aria-current="page")
539
      assert html =~ ~s(href="#pr-32")
540
      refute html =~ ~r|<a[^>]*>\s*Add auth|
541
    end
542
543
    test "carries each layer's pull request state as a data attribute and glyph" do
544
      assigns = %{layers: demo_layers()}
545
546
      html =
547
        rendered_to_string(
548
          ~H|<.stack_map id="stack-map" number={31} trunk="main" layers={@layers} />|
549
        )
550
551
      assert html =~ ~s(data-state="draft")
552
      assert html =~ ~s(data-state="merged")
553
      assert html =~ ~s(data-state="open")
554
      assert html =~ ~s(data-icon="pull-request-draft")
555
      assert html =~ ~s(data-icon="pull-request-merged")
556
      assert html =~ ~s(data-icon="pull-request-open")
557
      assert html =~ ~s(data-icon="branch")
558
    end
559
560
    test "shows the add row and links the trunk only when given a destination" do
561
      assigns = %{layers: demo_layers()}
562
563
      bare =
564
        rendered_to_string(
565
          ~H|<.stack_map id="stack-map" number={31} trunk="main" layers={@layers} />|
566
        )
567
568
      refute bare =~ "Add to stack"
569
      refute bare =~ ~r|<a[^>]*>\s*main<|
570
571
      linked =
572
        rendered_to_string(~H"""
573
        <.stack_map
574
          id="stack-map"
575
          number={31}
576
          trunk="main"
577
          trunk_href="#main"
578
          add_href="#add"
579
          layers={@layers}
580
        />
581
        """)
582
583
      assert linked =~ "Add to stack"
584
      assert linked =~ ~s(href="#add")
585
      assert linked =~ ~s(href="#main")
586
    end
587
588
    test "names the stack for assistive technology and renders the action slot" do
589
      assigns = %{layers: demo_layers()}
590
591
      html =
592
        rendered_to_string(~H"""
593
        <.stack_map id="stack-map" number={31} trunk="main" layers={@layers}>
594
          <:action><button type="button">Unstack</button></:action>
595
        </.stack_map>
596
        """)
597
598
      assert html =~ ~s(aria-label="Stack #31")
599
      assert html =~ "Unstack"
600
    end
601
  end
602
500 603
  defp purge(module) do
501 604
    :code.purge(module)
502 605
    :code.delete(module)

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