From 87033d19ab8d17fec21ece7e7580bbc7bd39ce7e Mon Sep 17 00:00:00 2001 From: David Grochowski Date: Fri, 6 Nov 2026 16:41:21 -0500 Subject: [PATCH 1/4] Reach discussions inside collapsed details sections A closed
in a plan hid the discussions anchored inside it: the highlight existed, so the thread was left out of the Comments list, but the reader could not see it, ?thread= links and j/k scrolled to an invisible mark, and Chrome still reported rects, so the margin marker sat beside the wrong text. - Every jump to a thread (?thread=, j/k, the review tray, anchor quotes, any popover open) opens the enclosing sections first. - Closed sections show a compact blue count badge on their summary instead of margin markers; it opens the section at the first discussion. Resolved threads count only while they are shown. - Opening or closing a section refreshes the margin markers. - Demo library: collapsed-discussions, with a nested section. --- AGENTS.md | 1 + docs/DEMO_LIBRARY.md | 1 + .../assets/stylesheets/coplan/comments.css | 32 +++++ .../coplan/comment_margin_controller.js | 64 +++++++-- .../coplan/comment_nav_controller.js | 2 + .../coplan/text_selection_controller.js | 25 +++- .../javascript/coplan/collapsed_sections.js | 33 +++++ .../guides/markdown.text.erb | 1 + engine/app/views/coplan/plans/show.html.erb | 2 +- engine/config/importmap.rb | 1 + .../db/demo/content/collapsed-discussions.md | 48 +++++++ engine/lib/coplan/development_seed.rb | 21 +++ spec/lib/development_seed_spec.rb | 29 ++++ .../system/collapsed_section_comments_spec.rb | 126 ++++++++++++++++++ 14 files changed, 374 insertions(+), 12 deletions(-) create mode 100644 engine/app/javascript/coplan/collapsed_sections.js create mode 100644 engine/db/demo/content/collapsed-discussions.md create mode 100644 spec/system/collapsed_section_comments_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 51a8aadc..e887ebfb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -231,6 +231,7 @@ reader to an apparently empty page. ### Inline review UI - **Highlights**: anchored text is wrapped in `` elements — subtle blue for `open`, unstyled for `resolved`; orange is reserved for viewer-specific attention, never thread status - **Margin dots**: colored indicators in the left margin aligned to each highlight's vertical position +- **Collapsed sections**: a closed `
` hides its highlights, so it shows a compact blue count badge on its summary instead of margin dots (open threads only, unless resolved threads are shown). Every jump to a thread — `?thread=`, j/k, a badge — opens the enclosing sections first (`coplan/collapsed_sections.js`) - **Thread popovers**: native HTML Popover API (`popover="auto"`) showing the comment thread, reply form, and action buttons; positioned relative to the anchor and tracked on scroll - **Comment toolbar**: fixed bottom bar showing open thread count, j/k navigation, and a "Show resolved" toggle diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 4b0a7c82..e2be11d2 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -59,6 +59,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | api-comment-anchors | Markdown text and table-row highlights; source-only discussion in Comments | | table-gallery | Precise cell discussion, repeated labels, empty values, links, wide expandable data | | comment-reactions | Discussions with emoji reactions; your own are pressed in blue, hover a chip for who reacted, add or remove one in place. Re-seeding keeps reactions you removed removed | +| collapsed-discussions | Discussions inside closed `
`, one nested; each summary counts the open discussions it hides. Select a count, press j, follow a `?thread=` link, or open a section to reach them | | formatting-gallery | Headings, editable template notes hidden while reading, emphasis, quotes, nested lists, tasks, two code languages, image, mention, footnote | | discussion-margins | Crowded prose discussions, multiple participants, resolved visibility, and slide navigation/scrolling; avatars identify starters, not unread activity | | reading-specimen | A realistic design doc for judging reading in both modes: prose, headings, lists, tasks, quote, table, footnote, image, SQL/Ruby/diff/YAML code, a long scrolling line, flowchart and sequence diagrams, plus open, agent-answered, resolved, diagram-node, table-cell, and general discussions | diff --git a/engine/app/assets/stylesheets/coplan/comments.css b/engine/app/assets/stylesheets/coplan/comments.css index 766233a8..54ebd1a5 100644 --- a/engine/app/assets/stylesheets/coplan/comments.css +++ b/engine/app/assets/stylesheets/coplan/comments.css @@ -207,6 +207,38 @@ background: var(--color-highlight-open-hover-bg); } +/* Discussions hidden in a closed
: a compact blue count on its + summary. The count is generated here so the summary's text, which + comment anchors match against, stays the author's. */ +.details-comment-badge { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: var(--space-sm); + padding: 1px 7px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--color-primary-light); + color: var(--color-primary); + font: 700 0.7rem/1.4 var(--font-sans); + vertical-align: middle; + cursor: pointer; +} + +.details-comment-badge::after { content: attr(data-count); } + +.details-comment-badge svg { + width: 12px; + height: 12px; +} + +.details-comment-badge:hover { border-color: var(--color-highlight-open-border); } + +.details-comment-badge:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + #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 index 49eb73c5..432ed1eb 100644 --- a/engine/app/javascript/controllers/coplan/comment_margin_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_margin_controller.js @@ -1,4 +1,5 @@ import { Controller } from "@hotwired/stimulus" +import { hidingSection } from "coplan/collapsed_sections" // Collapsed discussion markers in the page margin, one per highlighted // thread, aligned with the text they discuss. A marker shows the first @@ -7,7 +8,8 @@ import { Controller } from "@hotwired/stimulus" // // 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. +// threads or comments update them without a server round-trip. A closed +//
shows a count badge on its summary instead of markers. const STACK_GAP = 4 // Room beside the document card: gap from the card, gap from the window // edge, and the marker width. @@ -50,12 +52,25 @@ export default class extends Controller { 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 hidden = new Map() + const seen = new Set() + // Table-cell and diagram discussions have their own badges, so they + // only count toward a closed section. + for (const mark of this.content.querySelectorAll("mark.anchor-highlight[data-thread-id], [data-source-badge][data-thread-id]")) { const id = mark.dataset.threadId - if (marks.has(id) || !mark.getClientRects().length) continue + if (seen.has(id)) continue if (!showResolved && !mark.classList.contains("anchor-highlight--open")) continue - marks.set(id, mark) + const section = hidingSection(mark) + if (!section && (mark.hasAttribute("data-source-badge") || !mark.getClientRects().length)) continue + seen.add(id) + if (section) { + if (!hidden.has(section)) hidden.set(section, []) + hidden.get(section).push(id) + } else { + marks.set(id, mark) + } } + this.renderSectionBadges(hidden) for (const [id, card] of this.cards) { if (marks.has(id)) continue @@ -74,6 +89,32 @@ export default class extends Controller { this.position() } + // Each closed section counts the discussions it hides, in document order; + // its badge opens the section at the first one. + renderSectionBadges(sections) { + for (const badge of this.content.querySelectorAll(".details-comment-badge")) { + if (!sections.has(badge.closest("details"))) badge.remove() + } + for (const [details, ids] of sections) { + const summary = details.querySelector(":scope > summary") + if (!summary) continue + let badge = summary.querySelector(":scope > .details-comment-badge") + if (!badge) { + badge = document.createElement("button") + badge.type = "button" + badge.className = "details-comment-badge" + badge.dataset.action = "click->coplan--text-selection#openSectionThread" + // The count is CSS-generated: a text node here would enter the + // document text that comment anchors are matched against. + badge.innerHTML = COMMENT_ICON + summary.append(badge) + } + badge.dataset.threadId = ids[0] + badge.dataset.count = ids.length + badge.setAttribute("aria-label", `${ids.length} ${ids.length === 1 ? "discussion" : "discussions"} in this section`) + } + } + // Reuse card elements: an open discussion window tracks its trigger // element while the page scrolls. buildCard(id) { @@ -169,11 +210,16 @@ export default class extends Controller { } // 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$/, "")) + // opened (card, highlight, j/k, or a deep link). Opening or closing a + // section in the document shows or hides the markers inside it. + toggled(event) { + const target = event.target + if (target.tagName === "DETAILS" && this.content.contains(target)) { + this.scheduleRefresh() + return + } + if (!target.classList?.contains("thread-popover")) return + const card = this.cards.get(target.id.replace(/_popover$/, "")) card?.classList.toggle("is-active", event.newState === "open") } } diff --git a/engine/app/javascript/controllers/coplan/comment_nav_controller.js b/engine/app/javascript/controllers/coplan/comment_nav_controller.js index 1760240c..5227bae7 100644 --- a/engine/app/javascript/controllers/coplan/comment_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_nav_controller.js @@ -1,6 +1,7 @@ import { Controller } from "@hotwired/stimulus" import { registerShortcuts, commandFor } from "coplan/shortcuts" import { commentWindowTop } from "coplan/comment_window" +import { revealSections } from "coplan/collapsed_sections" export default class extends Controller { @@ -125,6 +126,7 @@ export default class extends Controller { if (slide) slide.dispatchEvent(new CustomEvent("coplan:deck-reveal", { bubbles: true, detail: { slide: slide.dataset.slide } })) + revealSections(mark) if (mark.hasAttribute("data-source-badge")) { const thread = document.getElementById(mark.dataset.threadId) if (thread) mark.dispatchEvent(new CustomEvent("coplan:source-thread", { diff --git a/engine/app/javascript/controllers/coplan/text_selection_controller.js b/engine/app/javascript/controllers/coplan/text_selection_controller.js index 1d866cbf..e893de26 100644 --- a/engine/app/javascript/controllers/coplan/text_selection_controller.js +++ b/engine/app/javascript/controllers/coplan/text_selection_controller.js @@ -1,5 +1,6 @@ import { Controller } from "@hotwired/stimulus" import { commentWindowTop } from "coplan/comment_window" +import { revealSections } from "coplan/collapsed_sections" // Elements whose text is never rendered but still appears in textContent — // e.g. the