diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 94fce7e9..cd9431de 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,8 @@ jobs:
# test jobs rather than gating them: a bracket-spacing nit should not hide
# whether the suite passes.
lint:
- runs-on: ubuntu-latest
+ # Pinned: setup-ruby has no Ruby 3.4.7 build for ubuntu-latest (26.04) yet.
+ runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
diff --git a/engine/app/assets/stylesheets/coplan/comments.css b/engine/app/assets/stylesheets/coplan/comments.css
index fc068613..bd7845fc 100644
--- a/engine/app/assets/stylesheets/coplan/comments.css
+++ b/engine/app/assets/stylesheets/coplan/comments.css
@@ -148,6 +148,63 @@
min-width: 0;
}
+/* Margin discussion markers: the first commenter's avatar beside the text a
+ discussion is about, in the page margin outside the document card so the
+ text keeps its width. comment_margin_controller sets the column's offset
+ and leaves it hidden when the margin has no room. */
+.comment-margin {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+}
+
+.comment-margin[hidden],
+.comment-margin:not([data-fits]) { display: none; }
+
+.comment-margin__card {
+ position: absolute;
+ left: 0;
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ padding: 0;
+ border: 2px solid var(--color-surface);
+ border-radius: 50%;
+ background: var(--color-surface);
+ box-shadow: var(--shadow-pop);
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: top 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
+}
+
+/* Set while the marker or its highlighted text is hovered. */
+.comment-margin__card.is-hover {
+ border-color: var(--color-highlight-open-border);
+ transform: scale(1.15);
+}
+
+.comment-margin__card:focus-visible {
+ outline: 2px solid var(--color-primary);
+ outline-offset: 2px;
+}
+
+.comment-margin__card.is-active { border-color: var(--color-primary); }
+
+.comment-margin__card--resolved { opacity: 0.6; }
+
+.comment-margin__card .avatar,
+.comment-margin__card .comment__agent-avatar {
+ width: 26px;
+ height: 26px;
+ font-size: 0.6rem;
+ border-radius: 50%;
+}
+
+.anchor-highlight--open.is-margin-hover {
+ background: var(--color-highlight-open-hover-bg);
+}
+
#plan-threads {
position: absolute;
width: 0;
diff --git a/engine/app/javascript/controllers/coplan/comment_margin_controller.js b/engine/app/javascript/controllers/coplan/comment_margin_controller.js
new file mode 100644
index 00000000..28bf0d87
--- /dev/null
+++ b/engine/app/javascript/controllers/coplan/comment_margin_controller.js
@@ -0,0 +1,173 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Collapsed discussion markers in the page margin, one per highlighted
+// thread, aligned with the text they discuss. A marker shows the first
+// commenter's avatar; clicking it opens the same discussion window as
+// clicking the highlight.
+//
+// Cards are derived from the highlights coplan--text-selection builds and
+// from the thread popovers in #plan-threads, so broadcasts that add
+// threads or comments update them without a server round-trip.
+const STACK_GAP = 4
+// Room beside the document card: gap from the card, gap from the window
+// edge, and the marker width.
+const GAP = 12
+const EDGE = 8
+const WIDTH = 30
+// Lucide message-circle, copyright 2026 Lucide Icons, ISC license.
+const COMMENT_ICON = ''
+
+export default class extends Controller {
+ connect() {
+ this.layout = this.element.closest(".plan-layout")
+ this.content = this.layout.querySelector("[data-coplan--text-selection-target~='content']")
+ this.cards = new Map()
+
+ // Comment appends, edits, and deletes change a thread's summary without
+ // re-running highlightAnchors.
+ const threads = this.layout.querySelector("#plan-threads")
+ if (threads) {
+ this.threadsObserver = new MutationObserver(() => this.scheduleRefresh())
+ this.threadsObserver.observe(threads, { childList: true, subtree: true })
+ }
+ // Images, diagrams, and expanded rows move the text after highlighting.
+ this.resizeObserver = new ResizeObserver(() => this.position())
+ this.resizeObserver.observe(this.content)
+ this.refresh()
+ }
+
+ disconnect() {
+ this.threadsObserver?.disconnect()
+ this.resizeObserver?.disconnect()
+ cancelAnimationFrame(this.refreshFrame)
+ }
+
+ scheduleRefresh() {
+ cancelAnimationFrame(this.refreshFrame)
+ this.refreshFrame = requestAnimationFrame(() => this.refresh())
+ }
+
+ refresh() {
+ const showResolved = !this.layout.classList.contains("plan-layout--hide-resolved")
+ const marks = new Map()
+ for (const mark of this.content.querySelectorAll("mark.anchor-highlight[data-thread-id]")) {
+ const id = mark.dataset.threadId
+ if (marks.has(id) || !mark.getClientRects().length) continue
+ if (!showResolved && !mark.classList.contains("anchor-highlight--open")) continue
+ marks.set(id, mark)
+ }
+
+ for (const [id, card] of this.cards) {
+ if (marks.has(id)) continue
+ card.remove()
+ this.cards.delete(id)
+ }
+ for (const [id, mark] of marks) {
+ const popover = document.getElementById(`${id}_popover`)
+ if (!popover) continue
+ const card = this.cards.get(id) || this.buildCard(id)
+ this.fillCard(card, popover, mark)
+ }
+
+ this.marks = marks
+ this.element.hidden = this.cards.size === 0
+ this.position()
+ }
+
+ // Reuse card elements: an open discussion window tracks its trigger
+ // element while the page scrolls.
+ buildCard(id) {
+ const card = document.createElement("button")
+ card.type = "button"
+ card.className = "comment-margin__card"
+ card.dataset.threadId = id
+ card.dataset.action = "click->coplan--text-selection#openThreadPopover"
+ this.element.append(card)
+ this.cards.set(id, card)
+ return card
+ }
+
+ // A card shows only the first commenter's avatar; the name and reply count
+ // are for assistive technology.
+ fillCard(card, popover, mark) {
+ const comments = popover.querySelectorAll(".thread-popover__comments > .comment:not(.comment--deleted)")
+ const header = comments[0]?.querySelector(".comment__header")
+ const avatar = header?.querySelector(".avatar, .comment__agent-avatar")?.cloneNode(true)
+ const author = header?.querySelector("strong")?.textContent.trim()
+ const replies = comments.length - 1
+ const resolved = !mark.classList.contains("anchor-highlight--open")
+ const signature = [author, replies, resolved, avatar?.outerHTML].join("\u0000")
+ if (card.dataset.signature === signature) return
+ card.dataset.signature = signature
+
+ card.classList.toggle("comment-margin__card--resolved", resolved)
+ if (avatar) {
+ avatar.removeAttribute("title")
+ avatar.setAttribute("aria-hidden", "true")
+ card.replaceChildren(avatar)
+ } else {
+ card.innerHTML = COMMENT_ICON
+ }
+ const label = [author ? `Discussion started by ${author}` : "Discussion"]
+ if (replies > 0) label.push(`${replies} ${replies === 1 ? "reply" : "replies"}`)
+ card.setAttribute("aria-label", label.join(", "))
+ }
+
+ // Place the column in the page margin right of the document card, then
+ // align each card with its highlight, pushing later cards down so they
+ // never overlap.
+ position() {
+ if (!this.marks || this.element.hidden) return
+ const layoutRect = this.layout.getBoundingClientRect()
+ const cardRight = (this.layout.closest(".plan-document") || this.layout).getBoundingClientRect().right
+ const fits = document.documentElement.clientWidth - cardRight - GAP - EDGE >= WIDTH
+ this.element.toggleAttribute("data-fits", fits)
+ if (!fits) return
+ this.element.style.left = `${cardRight + GAP - layoutRect.left}px`
+
+ const originTop = this.element.getBoundingClientRect().top
+ const placed = []
+ for (const [id, mark] of this.marks) {
+ const card = this.cards.get(id)
+ const rect = mark.getClientRects()[0]
+ if (!card || !rect) continue
+ placed.push({ card, top: rect.top + (rect.height - WIDTH) / 2 - originTop })
+ }
+ placed.sort((a, b) => a.top - b.top)
+ let floor = 0
+ for (const { card, top } of placed) {
+ const y = Math.max(top, floor)
+ card.style.top = `${y}px`
+ floor = y + card.offsetHeight + STACK_GAP
+ }
+ }
+
+ // Hovering a marker or any fragment of its highlight lights up both.
+ // Highlights are rebuilt by coplan--text-selection, so hover is tracked
+ // with one delegated document listener instead of per-mark bindings.
+ hoverChanged(event) {
+ const target = event.target.closest?.(".comment-margin__card, mark.anchor-highlight[data-thread-id]")
+ const inScope = target && (this.element.contains(target) || this.content.contains(target))
+ const id = inScope && this.cards.has(target.dataset.threadId) ? target.dataset.threadId : null
+ if (id === this.hoveredId) return
+ this.setHover(this.hoveredId, false)
+ this.hoveredId = id
+ this.setHover(id, true)
+ }
+
+ setHover(id, on) {
+ if (!id) return
+ this.cards.get(id)?.classList.toggle("is-hover", on)
+ this.content.querySelectorAll(`mark.anchor-highlight[data-thread-id="${id}"]`)
+ .forEach(mark => mark.classList.toggle("is-margin-hover", on))
+ }
+
+ // Keep the card for the open discussion visibly selected, however it was
+ // opened (card, highlight, j/k, or a deep link).
+ popoverToggled(event) {
+ const popover = event.target
+ if (!popover.classList?.contains("thread-popover")) return
+ const card = this.cards.get(popover.id.replace(/_popover$/, ""))
+ card?.classList.toggle("is-active", event.newState === "open")
+ }
+}
diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js
index 9dfe5ee9..5bd11c03 100644
--- a/engine/app/javascript/controllers/coplan/text_selection_controller.js
+++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js
@@ -512,6 +512,8 @@ export default class extends Controller {
_restoreActiveThreadPopover(threadId) {
if (!threadId || !this._activePopover) return
+ // Margin cards survive re-highlighting; keep the window beside its card.
+ if (this._activeMark?.isConnected && this._activeMark.closest(".comment-margin")) return
const replacementMark = this.contentTarget.querySelector(`.anchor-highlight[data-thread-id="${threadId}"]`)
if (replacementMark && this._findOpenPopover() === this._activePopover) {
diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb
index d5bd8fb3..850eba91 100644
--- a/engine/app/views/coplan/plans/show.html.erb
+++ b/engine/app/views/coplan/plans/show.html.erb
@@ -148,6 +148,11 @@
<%= render "coplan/plans/thread_collection", plan: @plan, threads: @threads %>
+ <%# Avatar markers in the page margin beside the text each discussion is
+ about. Positioned outside the card, so the text keeps its width. %>
+
diff --git a/spec/system/comment_ux_spec.rb b/spec/system/comment_ux_spec.rb
index 5e36dfde..d9718cba 100644
--- a/spec/system/comment_ux_spec.rb
+++ b/spec/system/comment_ux_spec.rb
@@ -278,6 +278,46 @@ def create_anchored_thread(plan:, anchor_text:, body:, user:)
expect(mark.evaluate_script("getComputedStyle(this).borderBottomStyle")).to eq("none")
end
+ it "shows an avatar marker in the margin for each open discussion that opens it" do
+ postgres = create_anchored_thread(plan: plan, anchor_text: "PostgreSQL", body: "Consider MySQL", user: reviewer)
+ opened = create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer)
+ opened.comments.create!(author_type: "human", author_id: author.id, body_markdown: "Scale needs.")
+ resolved = create_anchored_thread(plan: plan, anchor_text: "rate limiting", body: "Per user?", user: reviewer)
+ resolved.resolve!(author)
+
+ page.current_window.resize_to(1600, 900)
+ visit plan_page_path(plan)
+
+ # Resolved discussions stay hidden by default.
+ expect(page).to have_css(".comment-margin__card", count: 2)
+ opened_card = find(".comment-margin__card[data-thread-id='comment_thread_#{opened.id}']")
+ postgres_card = find(".comment-margin__card[data-thread-id='comment_thread_#{postgres.id}']")
+ expect(opened_card).to have_css(".avatar[aria-label='#{reviewer.name}']")
+ expect(opened_card[:"aria-label"]).to eq("Discussion started by #{reviewer.name}, 1 reply")
+
+ # Each marker sits in the page margin, outside the document, on the
+ # line it discusses.
+ middle = ->(element) { element.evaluate_script("(r => r.top + r.height / 2)(this.getClientRects()[0])") }
+ expect(postgres_card.evaluate_script("this.getBoundingClientRect().left"))
+ .to be > find(".plan-document").evaluate_script("this.getBoundingClientRect().right")
+ expect(middle.(opened_card)).to be_within(4).of(middle.(find("mark", text: "microservices architecture")))
+ expect(middle.(postgres_card)).to be_within(4).of(middle.(find("mark", text: "PostgreSQL")))
+
+ # Hover links a marker and its text in both directions.
+ find("mark", text: "PostgreSQL").hover
+ expect(page).to have_css(".comment-margin__card.is-hover", count: 1)
+ expect(postgres_card[:class]).to include("is-hover")
+ opened_card.hover
+ expect(page).to have_css("mark.is-margin-hover", count: 1, text: "microservices architecture")
+ expect(postgres_card[:class]).not_to include("is-hover")
+
+ postgres_card.click
+ within(".thread-popover:popover-open") { expect(page).to have_content("Consider MySQL") }
+ expect(page).to have_css(".comment-margin__card.is-active[data-thread-id='comment_thread_#{postgres.id}']")
+ ensure
+ page.current_window.resize_to(1400, 900)
+ end
+
it "does not toggle resolved visibility while typing s in a reply box" do
open_thread = create_anchored_thread(plan: plan, anchor_text: "microservices architecture", body: "Why not monolith?", user: reviewer)
resolved = create_anchored_thread(plan: plan, anchor_text: "PostgreSQL", body: "Consider MySQL", user: reviewer)