diff --git a/docs/DEMO_LIBRARY.md b/docs/DEMO_LIBRARY.md index 7d24a742..2b82feca 100644 --- a/docs/DEMO_LIBRARY.md +++ b/docs/DEMO_LIBRARY.md @@ -65,6 +65,7 @@ shipped types, including Scratchpad, PRD, Test Plan, and Handoff. | 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 | | document-title | One editable title; autosave keeps the URL stable until Done; title boundaries and empty-title behavior | | image-resizing | Resize and restore image widths, wrap prose from the top, Shift+Enter beside, Enter below | +| summary-block | A long analysis opening with a summary block: the labeled card, the pinned Summary outline entry, comments inside the card, the editor's Summary card, and unfurl text | | math-gallery | KaTeX math: inline, display, and fenced formulas, math in lists and tables, a six-row slide equation, dollar amounts that stay text, and invalid math shown as source; comment on the text around a formula | Flowcharts currently support source-backed node and edge comments. Other diff --git a/engine/app/assets/stylesheets/coplan/document-outline.css b/engine/app/assets/stylesheets/coplan/document-outline.css index 8129ddd1..5902e2f3 100644 --- a/engine/app/assets/stylesheets/coplan/document-outline.css +++ b/engine/app/assets/stylesheets/coplan/document-outline.css @@ -85,6 +85,24 @@ padding-left: var(--space-xl); } +/* Pinned first: the plan's summary block, set apart from the headings. */ +.content-nav__item--summary { + margin-bottom: var(--space-xs); + padding-bottom: var(--space-xs); + border-bottom: 1px solid var(--color-border); +} + +.content-nav__item--summary .content-nav__link-text::before { + content: ""; + display: inline-block; + width: 0.5em; + height: 0.5em; + margin-right: 0.45em; + border-radius: 2px; + background: var(--color-summary-border); + vertical-align: 0.05em; +} + .content-nav__link { display: flex; align-items: center; diff --git a/engine/app/assets/stylesheets/coplan/foundation.css b/engine/app/assets/stylesheets/coplan/foundation.css index 6e4dd99c..b08e7f33 100644 --- a/engine/app/assets/stylesheets/coplan/foundation.css +++ b/engine/app/assets/stylesheets/coplan/foundation.css @@ -143,6 +143,10 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(255, 213, 79, 0.08); --color-quote-info-border: rgba(108, 140, 255, 0.6); --color-quote-info-bg: rgba(108, 140, 255, 0.05); + /* Summary block: teal, so it is neither interaction blue nor attention orange. */ + --color-summary-bg: #f0fbf9; + --color-summary-border: #14b8a6; + --color-summary-label: #0f766e; --color-highlight-open-bg: rgba(108, 140, 255, 0.12); --color-highlight-open-hover-bg: rgba(108, 140, 255, 0.22); --color-highlight-open-border: rgba(108, 140, 255, 0.6); @@ -270,6 +274,9 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); --color-quote-info-border: rgba(96, 165, 250, 0.8); --color-quote-info-bg: rgba(96, 165, 250, 0.12); + --color-summary-bg: rgba(45, 212, 191, 0.09); + --color-summary-border: #2dd4bf; + --color-summary-label: #5eead4; --color-highlight-open-bg: rgba(96, 165, 250, 0.18); --color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28); --color-highlight-open-border: rgba(96, 165, 250, 0.85); @@ -363,6 +370,9 @@ body[data-writing-inspection] :is(#plan-toolbar a, .site-nav__edit) { opacity: . --color-quote-warning-bg-subtle: rgba(245, 158, 11, 0.12); --color-quote-info-border: rgba(96, 165, 250, 0.8); --color-quote-info-bg: rgba(96, 165, 250, 0.12); + --color-summary-bg: rgba(45, 212, 191, 0.09); + --color-summary-border: #2dd4bf; + --color-summary-label: #5eead4; --color-highlight-open-bg: rgba(96, 165, 250, 0.18); --color-highlight-open-hover-bg: rgba(96, 165, 250, 0.28); --color-highlight-open-border: rgba(96, 165, 250, 0.85); diff --git a/engine/app/assets/stylesheets/coplan/markdown.css b/engine/app/assets/stylesheets/coplan/markdown.css index f5e6bb7c..4aa47f9b 100644 --- a/engine/app/assets/stylesheets/coplan/markdown.css +++ b/engine/app/assets/stylesheets/coplan/markdown.css @@ -489,6 +489,35 @@ margin-bottom: var(--space-md); } +/* Summary block: the plan's 30-second read. The label is generated + content, so it is never part of the text a comment selection quotes; + the section's aria-label names it for assistive technology. */ +.markdown-rendered section.plan-summary { + margin: 0 0 var(--space-lg); + padding: var(--space-md) var(--space-lg); + border-left: 4px solid var(--color-summary-border); + border-radius: 0 var(--radius) var(--radius) 0; + background: var(--color-summary-bg); + scroll-margin-top: calc(var(--nav-height) + var(--space-lg)); +} + +.markdown-rendered section.plan-summary::before { + content: "Summary"; + content: "Summary" / ""; + display: block; + margin-bottom: var(--space-xs); + color: var(--color-summary-label); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.4; + text-transform: uppercase; +} + +.markdown-rendered section.plan-summary > :last-child { + margin-bottom: 0; +} + .markdown-rendered table { width: 100%; border-collapse: collapse; diff --git a/engine/app/assets/stylesheets/coplan/themes/neutral.css b/engine/app/assets/stylesheets/coplan/themes/neutral.css index d55e4e9a..e137802f 100644 --- a/engine/app/assets/stylesheets/coplan/themes/neutral.css +++ b/engine/app/assets/stylesheets/coplan/themes/neutral.css @@ -160,6 +160,9 @@ --table-numerals: tabular-nums; --image-radius: 6px; --blockquote-rule: var(--color-border-strong); + --color-summary-bg: light-dark(oklch(0.975 0.014 185), oklch(0.72 0.1 185 / 0.09)); + --color-summary-border: light-dark(oklch(0.62 0.09 185), oklch(0.72 0.09 185)); + --color-summary-label: light-dark(oklch(0.45 0.07 185), oklch(0.84 0.07 185)); --image-outline: 1px solid light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.1)); /* Mermaid palette, as hex (Mermaid's color math can't read oklch()). diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index 90b0ad33..644ffc8b 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -1,6 +1,7 @@ module CoPlan module MarkdownHelper include EmbedsHelper + include SummaryBlocksHelper ALLOWED_TAGS = %w[ h1 h2 h3 h4 h5 h6 p div span @@ -43,7 +44,7 @@ module MarkdownHelper # version. Bump it whenever the rendering pipeline changes output for the # same input (new tags, attribute changes, checkbox wiring, etc.), or # stale HTML will be served from cache. - RENDER_CACHE_VERSION = 29 + RENDER_CACHE_VERSION = 31 # Matches `[@username](mention:username)` where the bracket text and link # target encode the same username. Username allows letters, digits, dots, @@ -69,22 +70,28 @@ module MarkdownHelper # expandable_images: frame each image with the same full-window expander # diagrams and tables use. Follows data_tables by default, so decks opt # out of both together. - def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0, data_tables: true, expandable_images: data_tables, source_comments: false, retain_sourcepos: false) + # + # summary: a ContentRegions::Split::Summary whose marker lines are + # relative to `content`. render_content_regions finds the plan's one + # summary block and passes it to the fragment that contains it. + def render_markdown(content, interactive: true, footnote_prefix: nil, footnotes: :inline, line_offset: 0, data_tables: true, expandable_images: data_tables, source_comments: false, retain_sourcepos: false, summary: nil) render_options = { unsafe: true } # Sourcepos wires checkboxes and structural comment targets to source, # and tells parser-generated math from authored HTML that imitates it; # it is stripped after generating trusted interaction metadata. source = content.to_s.encode("UTF-8") - render_options[:sourcepos] = true if interactive || source.include?(ContentRegions::Iframe::PREFIX) || source.include?("$") || source.include?("math") - html = CoPlan::Markdown.to_html(source, options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil }) + render_options[:sourcepos] = true if interactive || summary || source.include?(ContentRegions::Iframe::PREFIX) || source.include?("$") || source.include?("math") + markdown = summary ? ContentRegions::Split.with_summary_breaks(source, summary) : source + html = CoPlan::Markdown.to_html(markdown, options: { extension: EXTENSION_OPTIONS, render: render_options }, plugins: { syntax_highlighter: nil }) with_chips = transform_mention_anchors(html) with_references = transform_reference_anchors(with_chips, numbered_sections: footnote_prefix.nil?) scrubber = LinkScrubber.new scrubber.tags = ALLOWED_TAGS scrubber.attributes = ALLOWED_ATTRIBUTES sanitized = sanitize(with_references, scrubber: scrubber) - sanitized = prepare_math(sanitized, source) + sanitized = prepare_math(sanitized, markdown) sanitized = render_iframe_blocks(sanitized, content.to_s) + sanitized = render_summary_block(sanitized, summary) result = interactive ? make_checkboxes_interactive(sanitized, content, line_offset: line_offset, source_comments: source_comments, retain_sourcepos: retain_sourcepos) : sanitized unless interactive fragment = Nokogiri::HTML::DocumentFragment.parse(result) @@ -181,7 +188,7 @@ def transform_reference_anchors(html, numbered_sections: true) def markdown_to_plain_text(content) source = content.to_s.encode("UTF-8") - source = ContentRegions::Split.call(source).canonical_source if source.include?("::: {.presentation") + source = ContentRegions::Split.call(source).canonical_source if ContentRegions::Split.applies?(source) html = CoPlan::Markdown.to_html(source, options: { extension: EXTENSION_OPTIONS }, plugins: { syntax_highlighter: nil }) Nokogiri::HTML::DocumentFragment.parse(html).text.squish end diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb index 97a4a35b..72d6d64b 100644 --- a/engine/app/helpers/coplan/plans_helper.rb +++ b/engine/app/helpers/coplan/plans_helper.rb @@ -151,22 +151,33 @@ def voice_button_description(hotkey) "Comment by voice — or hold #{User::VOICE_HOTKEY_LABELS[hotkey]} to talk" end - def plan_content_preview(plan, limit: 200) + # A workspace row's description: the author's summary block, then the + # generated abstract, then the plan's opening text. + def plan_row_summary(plan, limit: 200) stub = plan.current_version_stub - return nil if stub.nil? - - # Cached per content SHA: without this, every index page fell back to a - # full Commonmarker + Nokogiri parse per plan without an AI summary. - # The document body itself is fetched only on a cache miss — list pages - # preload just the stub (id + sha), never the MEDIUMTEXT columns. - cache_key = [ "coplan/plan-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id, - stub.content_sha256 || plan.current_revision ] - plain = Rails.cache.fetch(cache_key) do - markdown_to_plain_text(PlanVersion.where(id: stub.id).pick(:content_markdown)) + return plan.try(:summary).presence unless stub + + # Cache authored summary text independently of the generated abstract. + # Only fetch the body on a cache miss, and only parse the whole document + # when neither summary is available. Share the cold fetch between both. + cache_key = [ "coplan/plan-row-preview", MarkdownHelper::RENDER_CACHE_VERSION, plan.id, + stub.content_sha256 || plan.current_revision ] + content = nil + load_content = -> { content ||= PlanVersion.where(id: stub.id).pick(:content_markdown).to_s } + summary_block = Rails.cache.fetch([ *cache_key, "summary-block" ]) do + ContentRegions::Split.summary_text(load_content.call) end - return nil if plain.blank? + return truncate_preview(summary_block, limit) if summary_block + return plan.summary if plan.try(:summary).present? - truncate(plain, length: limit, omission: "…", separator: " ") + plain = Rails.cache.fetch([ *cache_key, "plain" ]) { markdown_to_plain_text(load_content.call) } + truncate_preview(plain, limit) + end + + private + + def truncate_preview(text, limit) + truncate(text, length: limit, omission: "…", separator: " ") if text.present? end end end diff --git a/engine/app/helpers/coplan/slideshows_helper.rb b/engine/app/helpers/coplan/slideshows_helper.rb index d86c3c55..cc61b10a 100644 --- a/engine/app/helpers/coplan/slideshows_helper.rb +++ b/engine/app/helpers/coplan/slideshows_helper.rb @@ -63,10 +63,10 @@ def render_slideshow(content, interactive: true, theme: "coplan", definitions: n # one whole-plan render. This keeps footnotes, link definitions and heading # IDs global rather than restarting them at each region boundary. def render_content_regions(content, interactive: true) - return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true) unless content.include?("::: {.presentation") - - result = ContentRegions::Split.call(content) - return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true) unless result.regions.any? { |region| region.kind == :presentation } + result = ContentRegions::Split.call(content) if ContentRegions::Split.applies?(content) + unless result&.regions&.any? { |region| region.kind == :presentation } + return render_markdown(content, interactive:, footnotes: :exclude, source_comments: true, summary: result&.summary) + end definitions = Slideshows::Split.call(result.canonical_source).definition_blocks deck_number = 0 @@ -100,12 +100,19 @@ def render_content_regions(content, interactive: true) fragment = Slideshows::Split::Slide.new(start_line: region.start_line, end_line: region.start_line + region.source.count("\n")) preamble = deck_preamble(definitions, fragment) + line_offset = region.start_line - 1 - preamble.count("\n") + # The summary cannot span a deck, so one document region holds it. + summary = result.summary if result.summary && result.summary.open_line >= fragment.start_line && + result.summary.close_line <= fragment.end_line + summary &&= ContentRegions::Split::Summary.new(open_line: summary.open_line - line_offset, + close_line: summary.close_line - line_offset) render_markdown(preamble + region.source, interactive:, footnotes: :exclude, retain_sourcepos: interactive, - line_offset: region.start_line - 1 - preamble.count("\n")) + line_offset:, summary:) end end rendered = Nokogiri::HTML::DocumentFragment.parse(safe_join(fragments)) document = Nokogiri::HTML::DocumentFragment.parse(render_markdown(result.canonical_source, interactive: false, data_tables: false)) + rendered.at_css("section.plan-summary")&.set_attribute("id", summary_id(document)) reconcile_references(rendered, document) Plans::SourceTargets.new(content).annotate(rendered) if interactive rendered.css("[data-sourcepos]").each { |element| element.remove_attribute("data-sourcepos") } diff --git a/engine/app/helpers/coplan/summary_blocks_helper.rb b/engine/app/helpers/coplan/summary_blocks_helper.rb new file mode 100644 index 00000000..18bcbc51 --- /dev/null +++ b/engine/app/helpers/coplan/summary_blocks_helper.rb @@ -0,0 +1,67 @@ +module CoPlan + module SummaryBlocksHelper + SUMMARY_CLASS = "plan-summary".freeze + SUMMARY_ID = "plan-summary".freeze + + # Wraps a summary block in section.plan-summary. The block was rendered + # with its marker lines as thematic breaks (Split.with_summary_breaks); + # the nodes between those two sibling breaks move into the + # section and the breaks are dropped. `summary` holds the marker lines + # in the coordinates of the rendered source, or nil for no block. + # + # Runs after sanitizing: raw HTML may write the wrapper's class or id, so + # those are removed first and only this transform can create the card. + # Nodes only move, and the markers never were text, so a selection inside + # the card resolves to the same source as the canonical text extraction. + def render_summary_block(html, summary) + return html unless summary || html.include?(SUMMARY_CLASS) + + doc = Nokogiri::HTML::DocumentFragment.parse(html) + doc.css("[class~='#{SUMMARY_CLASS}']").each do |node| + node.remove_attribute("id") if node["id"] == SUMMARY_ID + node["class"] = node.classes.reject { |name| name == SUMMARY_CLASS }.join(" ") + node.remove_attribute("class") if node["class"].empty? + end + + opener = summary && summary_break(doc, summary.open_line) + closer = summary && summary_break(doc, summary.close_line) + if opener && closer && opener.parent == closer.parent && + opener.parent.children.index(opener) < closer.parent.children.index(closer) + section = doc.document.create_element("section", class: SUMMARY_CLASS, id: summary_id(doc), "aria-label" => "Summary") + opener.add_previous_sibling(section) + node = opener.next_sibling + until node == closer + following = node.next_sibling + section.add_child(node) + node = following + end + end + opener&.remove + closer&.remove + doc.to_html + end + + private + + # Preserve existing heading and author fragments; the outline reads the + # actual card id rather than assuming a fixed anchor. + def summary_id(doc) + ids = doc.css("[id]").map { |node| node["id"] }.to_set + candidate = SUMMARY_ID + suffix = 2 + while ids.include?(candidate) + candidate = "#{SUMMARY_ID}-#{suffix}" + suffix += 1 + end + candidate + end + + # Raw HTML can carry its own data-sourcepos, so a line claimed by more + # than one break matches nothing. + def summary_break(doc, line) + pattern = /\A#{line}:\d+-#{line}:\d+\z/ + candidates = doc.css("hr[data-sourcepos]").select { |node| pattern.match?(node["data-sourcepos"].to_s) } + candidates.first if candidates.one? + end + end +end diff --git a/engine/app/javascript/controllers/coplan/content_nav_controller.js b/engine/app/javascript/controllers/coplan/content_nav_controller.js index 8cc6a746..05d67a83 100644 --- a/engine/app/javascript/controllers/coplan/content_nav_controller.js +++ b/engine/app/javascript/controllers/coplan/content_nav_controller.js @@ -54,14 +54,18 @@ export default class extends Controller { const headings = Array.from(documentRoot.querySelectorAll(".markdown-rendered h1, .markdown-rendered h2, .markdown-rendered h3")) // The title is part of the document and is a navigable heading too. this._headings = headings + // The server renders at most one summary block. Its entry is pinned + // first, wherever the block sits, so the 30-second read is one click away. + this._summary = documentRoot.querySelector(".markdown-rendered section.plan-summary[id]") - if (headings.length === 0) { + if (headings.length === 0 && !this._summary) { this.sidebarTarget.style.display = "none" if (this.hasShowBtnTarget) this.showBtnTarget.style.display = "none" return } this.sidebarTarget.style.display = "" if (this.hasShowBtnTarget) Object.assign(this.showBtnTarget.style, { display: "", visibility: "" }) + if (this._summary) this.appendSummaryItem(this._summary) // Commonmarker supplies an empty self-link inside each heading. Promote // its generated fragment to the heading itself when that heading does @@ -143,6 +147,23 @@ export default class extends Controller { this.updateCommentBadges() } + appendSummaryItem(summary) { + const li = document.createElement("li") + li.className = "content-nav__item content-nav__item--summary" + li.dataset.headingId = summary.id + const a = document.createElement("a") + a.className = "content-nav__link" + a.href = `#${summary.id}` + a.addEventListener("click", event => this.handleLinkClick(event, summary)) + const text = document.createElement("span") + text.className = "content-nav__link-text" + text.textContent = "Summary" + a.appendChild(text) + li.appendChild(a) + this.listTarget.appendChild(li) + this._itemsById.set(summary.id, li) + } + // ProseMirror owns every node under its root. The reader's heading IDs, // title spans and permalink links cannot be inserted there: its next DOM // reconciliation would discard them and could move the caret. Keep the @@ -152,6 +173,7 @@ export default class extends Controller { // last heading must not move the caret or a toolbar button mid-click. if (this._editorRoot !== root) this._editorSidebarDisplayed = this.sidebarTarget.style.display !== "none" this._editorRoot = root + this._summary = null this._headings = this.editorHeadings() this._editorHeadingSignature = this.editorHeadingSignature(this._headings) this._outlineIds = [] @@ -222,7 +244,7 @@ export default class extends Controller { if (this._scrollHandler) { window.removeEventListener("scroll", this._scrollHandler) } - if (!this._headings || this._headings.length === 0) return + if (!this._headings?.length && !this._summary) return this._scrollHandler = () => { if (this._ignoreScroll) { @@ -280,10 +302,7 @@ export default class extends Controller { _updateActiveFromScroll() { const threshold = 100 let active = null - const headings = this._editorRoot ? this.editorHeadings() : this._headings.filter(heading => { - const slide = heading.closest(".deck-slide") - return !slide || slide.classList.contains("deck-slide--current") - }) + const headings = this._editorRoot ? this.editorHeadings() : this.readingTargets() for (const heading of headings) { if (heading.getBoundingClientRect().top <= threshold) { active = heading @@ -300,6 +319,18 @@ export default class extends Controller { } } + // Outline targets in document order: visible headings, plus the summary + // block so its entry is current while it is being read. + readingTargets() { + const headings = this._headings.filter(heading => { + const slide = heading.closest(".deck-slide") + return !slide || slide.classList.contains("deck-slide--current") + }) + if (!this._summary) return headings + return [...headings, this._summary].sort((a, b) => + a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1) + } + handleLinkClick(event, heading, outlineId = heading?.id) { event.preventDefault() if (!heading) return diff --git a/engine/app/javascript/coplan/content_sections.js b/engine/app/javascript/coplan/content_sections.js index 1f904020..5958d910 100644 --- a/engine/app/javascript/coplan/content_sections.js +++ b/engine/app/javascript/coplan/content_sections.js @@ -1,5 +1,6 @@ // The rendered document may alternate prose and slide roots. Walk their // blocks in document order so section keys remain global across regions. +// A summary card is a wrapper too: its blocks belong to the sections around it. export function renderedBlocks(root) { const regions = root.matches?.(".markdown-rendered") ? [root] : Array.from(root.querySelectorAll(".markdown-rendered")) @@ -8,5 +9,5 @@ export function renderedBlocks(root) { function logicalChildren(parent) { return Array.from(parent.children).flatMap(child => - child.matches(".deck-body, .deck-media") ? logicalChildren(child) : [child]) + child.matches(".deck-body, .deck-media, .plan-summary") ? logicalChildren(child) : [child]) } diff --git a/engine/app/javascript/coplan/rich_document.js b/engine/app/javascript/coplan/rich_document.js index bbf80187..4fe08a32 100644 --- a/engine/app/javascript/coplan/rich_document.js +++ b/engine/app/javascript/coplan/rich_document.js @@ -308,22 +308,53 @@ const defaultMarkdownSerializer = new MarkdownSerializer({ math: { open: "", close: "", escape: false } }) const presentationOpen = /^::: \{\.presentation(?: #([a-zA-Z][\w-]*))?(?: theme="(coplan|graphite)")?\}$/ +const summaryOpen = "::: {.summary}" function contentRanges(tokens, lines, offsets, markdown, definitionsOnly = false) { const ranges = tokens.filter(t => t.level === 0 && t.map && t.nesting !== -1) const specials = [] - let opener = null + // Mirrors ContentRegions::Split. Presentation markers are standalone + // paragraphs. Summary markers are whole lines outside code and raw HTML, + // because `- Last point` then `:::` folds the closer into the list. + const markers = new Map() for (const token of ranges) { const [from, to] = token.map if (token.type !== "paragraph_open" || to !== from + 1) continue const line = lines[from].replace(/\r$/, "") - if (opener !== null) { - if (line !== ":::") continue - specials.push({ from: offsets[opener], to: Math.min(offsets[to], markdown.length), kind: "presentation" }) - opener = null - } else if (presentationOpen.test(line)) opener = from + if (line === ":::" || presentationOpen.test(line)) markers.set(from, { line, paragraph: true }) if (line.startsWith("::: {.iframe ") && line.endsWith(" /}")) specials.push({ from: offsets[from], to: Math.min(offsets[to], markdown.length), kind: "iframe" }) } + if (markdown.includes(summaryOpen)) { + // The rich parser disables HTML; scan literal boundaries with the + // HTML-aware parser so examples cannot consume the real summary. + const literal = htmlBlockTokenizer.parse(markdown, {}).filter(t => t.map && ["fence", "code_block", "html_block"].includes(t.type)) + for (const token of literal) { + for (let line = token.map[0]; line < token.map[1]; line++) markers.delete(line) + } + lines.forEach((raw, index) => { + const line = raw.replace(/\r$/, "") + if ((line === summaryOpen || line === ":::") && !markers.has(index) && + !literal.some(t => index >= t.map[0] && index < t.map[1])) markers.set(index, { line, paragraph: false }) + }) + } + // The first closed summary block is one card; a presentation opener + // inside it leaves the summary as text. + let opener = null, summaryOpener = null, summaryFound = false + for (const [index, { line, paragraph }] of [...markers].sort((a, b) => a[0] - b[0])) { + const to = Math.min(offsets[index + 1], markdown.length) + if (opener !== null) { + if (!paragraph || line !== ":::") continue + specials.push({ from: offsets[opener], to, kind: "presentation" }) + opener = null + } else if (paragraph && presentationOpen.test(line)) { + summaryOpener = null + opener = index + } else if (summaryOpener !== null && line === ":::") { + specials.push({ from: offsets[summaryOpener], to, kind: "summary" }) + summaryOpener = null + summaryFound = true + } else if (line === summaryOpen && !summaryFound && summaryOpener === null) summaryOpener = index + } // Definitions are metadata, not visible body blocks. Keep their exact source, // including indented continuation paragraphs, for citation editing and Raw. const excluded = ranges.filter(t => ["fence", "code_block", "html_block", "blockquote_open", "bullet_list_open", "ordered_list_open"].includes(t.type)) @@ -525,7 +556,7 @@ export function parseDocument(markdown) { let unsupported = text.replace(imageTag, tag => htmlImage(tag) ? "" : tag) // Raw TeX such as "$a { if (child.isText && schema.marks.math.isInSet(child.marks)) unsupported = unsupported.replace(child.text, "") }) - if (parsed.firstChild?.type.name !== "code_block" && /\]\s*\[|<\/?[a-z!]|^\s*\[[^\]]+\]:|^:::(?: \{\.presentation.*\})?$/im.test(unsupported)) throw new Error("preserve") + if (parsed.firstChild?.type.name !== "code_block" && /\]\s*\[|<\/?[a-z!]|^\s*\[[^\]]+\]:|^:::(?: \{\.(?:presentation.*|summary)\})?$/im.test(unsupported)) throw new Error("preserve") if (parsed.childCount !== 1) throw new Error("preserve") let node = schema.nodeFromJSON(parsed.firstChild.toJSON()) node = node.type.create({ ...node.attrs, source: text, snapshot: signature(node) }, node.content, node.marks) @@ -962,7 +993,7 @@ export function createRichDocument(element, markdown, changed, selectionChanged } view.updateState(view.state.applyTransaction(transaction).state) if (transaction.docChanged) { - for (const block of view.dom.querySelectorAll(".document-editor__content-block")) block.coplanRefreshPreview?.() + for (const block of view.dom.querySelectorAll(".document-editor__content-block, .document-editor__summary-block")) block.coplanRefreshPreview?.() clearTimeout(previewOffsetTimer) previewOffsetTimer = setTimeout(() => { for (const body of view.dom.querySelectorAll(".document-editor__block-preview[data-source-from]")) { @@ -1443,6 +1474,18 @@ function blockChrome(getPos, label) { header.append(title, edit, copy); dom.append(header) return { dom, header, title, edit, copy } } + +// The reading view owns the card's id and its outline entry. +function summaryPreview(html) { + const template = document.createElement("template"); template.innerHTML = html + const card = template.content.querySelector("section.plan-summary") + if (!card) throw new Error("Summary unavailable") + card.removeAttribute("id") + const wrapper = card.closest(".markdown-rendered")?.cloneNode(false) || document.createElement("div") + wrapper.classList.add("markdown-rendered"); wrapper.append(card) + return wrapper.outerHTML +} + function previewSettled(body, view, getPos, sourceRangeAt) { const updateRange = (force = false) => { const range = sourceRangeAt(view, getPos()) @@ -1627,24 +1670,40 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { dom.append(input) } const body = document.createElement("div"); body.className = "document-editor__block-preview"; dom.append(body) + let previewTimer, renderedSource const render = async () => { const sequence = ++generation, source = node.attrs.source dom.hidden = !source.trim() if (dom.hidden) return - const isTable = node.attrs.kind !== "list" && /^\s*\|?.*\|.*\n\s*\|?\s*:?-{3,}/m.test(source) - const isReferences = /^\s*\[\^[^\]]+\]:/m.test(source) - title.textContent = node.attrs.kind === "list" ? "List" : isTable ? "Table" : isReferences ? "References" : "Markdown block" - body.replaceChildren() - const fallback = document.createElement("pre"); fallback.textContent = source; body.append(fallback) + const summary = node.attrs.kind === "summary" + const isTable = !summary && node.attrs.kind !== "list" && /^\s*\|?.*\|.*\n\s*\|?\s*:?-{3,}/m.test(source) + const isReferences = !summary && /^\s*\[\^[^\]]+\]:/m.test(source) + title.textContent = summary ? "Summary" : node.attrs.kind === "list" ? "List" : isTable ? "Table" : isReferences ? "References" : "Markdown block" + if (!summary || !body.querySelector("section.plan-summary")) { + body.replaceChildren() + const fallback = document.createElement("pre"); fallback.textContent = source; body.append(fallback) + } // Rendering definitions alone drops them as unused footnotes. Keep their // source visible so References cards never become empty previews. if (preview && !isReferences) { try { - const html = await preview(source) - if (sequence === generation) { body.innerHTML = html; previewSettled(body, view, getPos, sourceRangeAt) } + // The summary previews from the whole plan, so its citations keep + // their numbers; only its card is shown. + const previewSource = summary ? serializeDocument(view.state.doc) : source + let html = await preview(previewSource) + if (summary) html = summaryPreview(html) + if (sequence === generation) { renderedSource = previewSource; body.innerHTML = html; previewSettled(body, view, getPos, sourceRangeAt) } } catch { /* Source remains usable offline. */ } } } + if (node.attrs.kind === "summary") { + dom.classList.add("document-editor__summary-block") + dom.coplanRefreshPreview = () => { + if (renderedSource === serializeDocument(view.state.doc)) return + generation++; clearTimeout(previewTimer) + previewTimer = setTimeout(render, 300) + } + } render() return { dom, update(next) { if (next.type !== node.type || next.attrs.kind !== node.attrs.kind) return false; if (next.attrs.source !== node.attrs.source) { node = next @@ -1652,7 +1711,7 @@ function sourceNodeView(initial, view, getPos, preview, sourceRangeAt) { if (input && input.value !== node.attrs.source) input.value = node.attrs.source render() } return true }, - stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++ } } + stopEvent: () => true, ignoreMutation: () => true, destroy() { generation++; clearTimeout(previewTimer) } } } function codeNodeView(initial, view, getPos, preview, sourceRangeAt) { let node = initial, generation = 0, timer diff --git a/engine/app/models/coplan/comment_thread.rb b/engine/app/models/coplan/comment_thread.rb index 97b700aa..e274ff5e 100644 --- a/engine/app/models/coplan/comment_thread.rb +++ b/engine/app/models/coplan/comment_thread.rb @@ -347,8 +347,11 @@ def resolve_anchor_position occurrence = self.anchor_occurrence || 1 return if occurrence < 1 - # First, try an exact match against the raw markdown. - ranges = find_all_occurrences(content, anchor_text) + # First, try an exact match against the raw markdown. Region markers + # such as `::: {.summary}` are not text a reader can select, so match + # against the canonical source, which blanks them in place. + raw = ContentRegions::Split.applies?(content) ? ContentRegions::Split.call(content).canonical_source : content + ranges = find_all_occurrences(raw, anchor_text) # If no exact match, the selected text may span markdown formatting # (e.g. DOM text "Hello me you" vs raw "Hello `me` you", or table diff --git a/engine/app/services/coplan/content_regions/split.rb b/engine/app/services/coplan/content_regions/split.rb index 20cde8a3..781abc3b 100644 --- a/engine/app/services/coplan/content_regions/split.rb +++ b/engine/app/services/coplan/content_regions/split.rb @@ -3,40 +3,112 @@ module ContentRegions # A small fenced-div extension over CommonMark. Only standalone top-level # paragraphs can be delimiters, so code examples and quoted fences remain # ordinary Markdown. Unknown or unclosed fences remain visible source. + # + # A summary block is ordinary Markdown between two marker lines. Authors + # write it without blank lines (`- Last point` then `:::`), where + # CommonMark would fold the closer into the list item, so its markers are + # whole lines outside code and raw HTML rather than paragraphs. Only the + # first closed block counts; later ones stay visible source. It cannot + # contain a presentation: an opener before the summary's closer leaves + # the summary marker as text, so the closer still belongs to the deck. class Split Region = Struct.new(:kind, :source, :start_line, :id, :theme, keyword_init: true) - Result = Struct.new(:regions, :canonical_source, keyword_init: true) + Summary = Struct.new(:open_line, :close_line, keyword_init: true) + Result = Struct.new(:regions, :canonical_source, :summary, keyword_init: true) OPEN = /\A::: \{\.presentation(?: #([a-zA-Z][\w-]*))?(?: theme="(coplan|graphite)")?\}\z/ + SUMMARY_OPEN = "::: {.summary}" CLOSE = ":::" + PLAIN_TEXT_BLOCKS = %i[paragraph heading item table_row table_cell block_quote].freeze def self.call(source) new(source).call end + # Cheap pre-check before parsing: whether the source can contain a + # marker this class would treat as structure. + def self.applies?(source) + source.to_s.include?("::: {.presentation") || source.to_s.include?(SUMMARY_OPEN) + end + + # Markdown for rendering a summary block: its marker lines become + # thematic breaks of the same length. A break ends any list, table or + # paragraph above it, as a blank line would not, and leaves every source + # offset and line ending in place. `summary` is in this source's lines. + def self.with_summary_breaks(source, summary) + lines = source.to_s.split("\n", -1) + [ summary.open_line, summary.close_line ].each do |number| + line = lines[number - 1] + ending = line.end_with?("\r") ? "\r" : "" + lines[number - 1] = "***".ljust(line.length - ending.length) + ending + end + lines.join("\n") + end + + # The summary block's reading text for previews and unfurls, or nil when + # the plan has no block or the block has no text. Citation markers, + # images and HTML tags are not part of the sentence a reader sees. + def self.summary_text(source) + return unless source.to_s.include?(SUMMARY_OPEN) + + result = new(source).call + summary = result.summary + return unless summary + + document = CoPlan::Markdown.parse(result.canonical_source, options: { extension: MarkdownHelper::EXTENSION_OPTIONS }) + text = document.select do |node| + node.source_position[:start_line] > summary.open_line && node.source_position[:end_line] < summary.close_line + end.map { |node| plain_text(node) }.join(" ") + text.squish.presence + end + + def self.plain_text(node) + case node.type + when :text, :code then node.string_content + when :code_block then " #{node.string_content} " + when :softbreak, :linebreak then " " + when :footnote_reference, :footnote_definition, :image, :html_inline, :html_block then "" + else + inner = node.map { |child| plain_text(child) }.join + PLAIN_TEXT_BLOCKS.include?(node.type) ? " #{inner} " : inner + end + end + private_class_method :plain_text + def initialize(source) @source = source.to_s.encode("UTF-8").delete("\r") end def call lines = @source.split("\n", -1) + document = CoPlan::Markdown.parse(@source, options: { extension: MarkdownHelper::EXTENSION_OPTIONS }) markers = {} - CoPlan::Markdown.parse(@source, options: { extension: MarkdownHelper::EXTENSION_OPTIONS }).each do |node| + document.each do |node| next unless node.type == :paragraph pos = node.source_position next unless pos[:start_line] == pos[:end_line] line = lines[pos[:start_line] - 1] - markers[pos[:start_line]] = line if line == CLOSE || line.match?(OPEN) + markers[pos[:start_line]] = { line:, paragraph: true } if line == CLOSE || line.match?(OPEN) + end + if @source.include?(SUMMARY_OPEN) + literal = literal_lines(document) + lines.each.with_index(1) do |line, number| + next unless line == SUMMARY_OPEN || line == CLOSE + markers[number] ||= { line:, paragraph: false } unless literal.include?(number) + end end regions = [] canonical = lines.dup cursor = 1 opener = nil - markers.sort.each do |number, line| + summary_opener = nil + summary = nil + markers.sort.each do |number, marker| + line = marker[:line] if opener - next unless line == CLOSE + next unless marker[:paragraph] && line == CLOSE add_region(regions, lines, :document, cursor, opener[:line] - 1) add_region(regions, lines, :presentation, opener[:line] + 1, number - 1, @@ -47,16 +119,36 @@ def call canonical[number - 1] = " " * lines[number - 1].length cursor = number + 1 opener = nil - elsif (match = OPEN.match(line)) + elsif marker[:paragraph] && (match = OPEN.match(line)) + summary_opener = nil opener = { line: number, id: match[1], theme: match[2] || "coplan" } + elsif summary_opener && line == CLOSE + summary = Summary.new(open_line: summary_opener, close_line: number) + summary_opener = nil + elsif line == SUMMARY_OPEN && summary.nil? && summary_opener.nil? + summary_opener = number end end add_region(regions, lines, :document, cursor, lines.length) - Result.new(regions:, canonical_source: canonical.join("\n")) + canonical_source = canonical.join("\n") + canonical_source = self.class.with_summary_breaks(canonical_source, summary) if summary + Result.new(regions:, canonical_source:, summary:) end private + # Lines that Markdown shows verbatim: code and raw HTML blocks at any + # depth. A marker written there is an example, not structure. + def literal_lines(document) + lines = Set.new + document.walk do |node| + next unless %i[code_block html_block].include?(node.type) + + lines.merge(node.source_position[:start_line]..node.source_position[:end_line]) + end + lines + end + def add_region(regions, lines, kind, first, last, id: nil, theme: nil) return if first > last && kind != :presentation diff --git a/engine/app/services/coplan/link_previews.rb b/engine/app/services/coplan/link_previews.rb index 1598a455..90a54ff4 100644 --- a/engine/app/services/coplan/link_previews.rb +++ b/engine/app/services/coplan/link_previews.rb @@ -40,7 +40,9 @@ def for_plan(plan, base_url:) path_suffix = plan.url_path || "plans/#{plan.id}" canonical.path = join_path(base.path, encode_path_segments(path_suffix)) canonical.query = canonical.fragment = nil - description = plan.summary.presence || plain_content(plan) + # The author's summary block outranks the generated abstract. + content = plan.current_content + description = ContentRegions::Split.summary_text(content) || plan.summary.presence || plain_content(content) LinkPreview.new( kind: "plan", @@ -179,8 +181,7 @@ def decode_path_segments(path) path.split("/").map { |seg| CGI.unescape(seg) }.join("/") end - def plain_content(plan) - content = plan.current_content + def plain_content(content) content.present? ? Plans::MarkdownTextExtractor.call(content).first.squish : nil end diff --git a/engine/app/services/coplan/plans/markdown_text_extractor.rb b/engine/app/services/coplan/plans/markdown_text_extractor.rb index f9843e4b..4cf4f176 100644 --- a/engine/app/services/coplan/plans/markdown_text_extractor.rb +++ b/engine/app/services/coplan/plans/markdown_text_extractor.rb @@ -17,7 +17,7 @@ def self.call(content, exclude_nonprose: false, source_spans: false) end def initialize(content, exclude_nonprose: false, source_spans: false) - @content = content.to_s.include?("::: {.presentation") ? ContentRegions::Split.call(content).canonical_source : content + @content = ContentRegions::Split.applies?(content.to_s) ? ContentRegions::Split.call(content).canonical_source : content @exclude_nonprose = exclude_nonprose @source_lines = @content.lines if exclude_nonprose || source_spans @source_spans = [] if source_spans diff --git a/engine/app/services/coplan/plans/source_targets.rb b/engine/app/services/coplan/plans/source_targets.rb index c6dc82fc..92b6b053 100644 --- a/engine/app/services/coplan/plans/source_targets.rb +++ b/engine/app/services/coplan/plans/source_targets.rb @@ -32,8 +32,12 @@ def initialize(content) @digest = Digest::SHA256.hexdigest(@content) # Raw HTML may contain data-sourcepos supplied by the author. Only # positions produced by the Markdown AST can become signed targets. + # Parse what the renderer parsed: a summary block's markers end the + # blocks around them, which can move a neighbouring table's span. @positions = Hash.new { |hash, key| hash[key] = Set.new } - collect_positions(CoPlan::Markdown.parse(@content, options: { extension: MarkdownHelper::EXTENSION_OPTIONS })) + summary = ContentRegions::Split.call(@content).summary if @content.include?(ContentRegions::Split::SUMMARY_OPEN) + @markdown = summary ? ContentRegions::Split.with_summary_breaks(@content, summary) : @content + collect_positions(CoPlan::Markdown.parse(@markdown, options: { extension: MarkdownHelper::EXTENSION_OPTIONS })) end def annotate(doc) @@ -89,7 +93,7 @@ def annotate(doc) # Used after OT: a surviving range must still describe a selectable # element, rather than syntax that an edit elsewhere turned into prose. def ranges - html = CoPlan::Markdown.to_html(@content, options: { extension: MarkdownHelper::EXTENSION_OPTIONS, + html = CoPlan::Markdown.to_html(@markdown, options: { extension: MarkdownHelper::EXTENSION_OPTIONS, render: { sourcepos: true } }, plugins: { syntax_highlighter: nil }) doc = annotate(Nokogiri::HTML.fragment(html)) targets = doc.css("[data-source-target]").map { |node| JSON.parse(node["data-source-target"]) } diff --git a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb index 582ceb8b..6a08c215 100644 --- a/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/markdown.text.erb @@ -4,6 +4,26 @@ Plans use CommonMark with the GitHub extensions: tables, task lists, strikethrou This guide assumes that you have read the primer at <<%= @base %>/agent-instructions>. +## Summary block + +Start a long plan with a summary that a reader can finish in 30 seconds. Put it right after the title, before the first section: + +```markdown +::: {.summary} +- **Recommendation:** move webhook delivery to a durable outbox. +- Retries drop from hours to minutes; no new infrastructure is needed. +- Decision needed by Friday. +::: +``` + +- Put the opening and closing lines each on their own line, with nothing else on the line, outside lists, quotes, and code. +- Write ordinary Markdown between them: lists, bold, links, and footnotes work as usual. +- If you wrap the summary in HTML, keep the whole block inside one container. Do not open or close that container midway through the block. +- State the conclusion, the impact, and the decision or next step. Leave the evidence to the sections below. +- Use one summary block per plan. Coplan shows only the first as a summary; a second one shows its `:::` lines as plain text. + +Readers see the block as a labeled card with a "Summary" link pinned at the top of the outline. Link previews, such as Slack unfurls, use its text instead of the generated summary. Short plans do not need one. + ## Template notes Put author guidance in a standalone HTML comment: ``. diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index 815561c4..46e2c014 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -149,7 +149,7 @@ Fetch a guide when your task needs it. Each guide is Markdown, like this page. | Task | Guide | |---|---| | Connecting a computer without a credential | <<%= @base %>/agent-instructions/connecting> | -| Diagrams, tables, code blocks, footnotes, section links, task lists, images | <<%= @base %>/agent-instructions/markdown> | +| Summary blocks, diagrams, tables, code blocks, footnotes, section links, task lists, images | <<%= @base %>/agent-instructions/markdown> | | Slide decks inside a plan (`::: {.presentation}` regions) | <<%= @base %>/agent-instructions/presentations> | - Embedded pages: <<%= @base %>/agent-instructions/embeds> | Plan types, folders, tags, references, visibility, archiving | <<%= @base %>/agent-instructions/creating> | diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index c759e88b..2dde8331 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -6,7 +6,7 @@ draggable, so without it a drag that starts on the title picks up the *link* payload instead of the row's — the "only works if you grab the right spot" bug. %> -<% summary = plan.try(:summary).presence || plan_content_preview(plan) %> +<% summary = plan_row_summary(plan) %> <% folder_id = plan_folder_id(plan) %>
" diff --git a/engine/db/demo/content/summary-block.md b/engine/db/demo/content/summary-block.md new file mode 100644 index 00000000..20a93fac --- /dev/null +++ b/engine/db/demo/content/summary-block.md @@ -0,0 +1,76 @@ +::: {.summary} +- **Recommendation:** move search indexing off the request path and onto the existing job queue. It is the only option that fixes the slowdown without new infrastructure. +- Indexing on save adds a median of **310 ms** to every plan save and is the main cause of the March regression.[^profile] +- Rollout takes two weeks behind a flag. Search results lag saves by under five seconds. +- **Decision needed:** approve the queue option by Friday so the work fits the next release. See [§5 Options](#section-5). +::: + +Select any sentence in the summary to comment on it, or use **Summary** at the top of the outline to jump back here. Open **Edit** to see the block as one card in the rich editor; Raw Markdown shows the `::: {.summary}` lines. + +## 1 Background + +In March, people started reporting that saving a long plan felt slow. Saves that used to finish in about 200 ms now often take more than half a second. The slowdown is worse for plans with many tables and diagrams. + +We shipped two changes that month: full-text search, which indexes a plan each time it is saved, and a larger default font for headings. Only the first touches the save path. + +## 2 What we measured + +We timed 4,000 saves on staging, split by plan size. Each save was repeated with indexing on and off. + +| Plan size | Median save, indexing off | Median save, indexing on | Added | +|---|---:|---:|---:| +| Under 2,000 words | 180 ms | 290 ms | 110 ms | +| 2,000–8,000 words | 210 ms | 520 ms | 310 ms | +| Over 8,000 words | 260 ms | 1,140 ms | 880 ms | + +The added time grows with the length of the plan. Database writes stay flat. + +## 3 Where the time goes + +Most of the added time is spent extracting plain text and rebuilding the search index row inside the save transaction. The transaction holds a row lock while it waits, so concurrent comments on the same plan wait too. + +```mermaid +sequenceDiagram + participant Browser + participant App + participant Database + Browser->>App: Save plan + App->>Database: Write version + App->>App: Extract text and build index row + App->>Database: Write index row + App-->>Browser: Saved +``` + +## 4 Constraints + +- Search results may lag a save by a few seconds. Nobody searches for text they typed a moment ago. +- We cannot add a new datastore this quarter. +- Saves must keep their current guarantees: one version per save, and no lost edits. + +## 5 Options + +### 5.1 Index on the job queue + +Write the version, then enqueue an indexing job after the transaction commits. The queue already runs comment notifications, so it is monitored and has spare capacity. + +### 5.2 Make extraction faster + +Cache the extracted text per section and rebuild only changed sections. This helps long plans the most, but it keeps indexing on the request path and adds a cache that must stay correct. + +### 5.3 Index on a schedule + +Rebuild the index every few minutes for recently saved plans. This is simple, but results lag by minutes rather than seconds. + +## 6 Recommendation and rollout + +Choose option 5.1. Ship it behind a flag, turn it on for the demo workspace first, then for everyone after a week without indexing errors. + +- [ ] Add the indexing job and enqueue it after commit +- [ ] Backfill plans saved while the flag was off +- [ ] Add an alert for jobs that wait longer than a minute + +## 7 Risks + +If the queue backs up, search results go stale but saves stay fast. The alert above covers that case. A failed job retries with the existing retry policy. + +[^profile]: Measured on staging with request profiling across 4,000 saves. Indexing accounted for 71% of the added time; the rest was the larger transaction holding locks longer. diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index a2c3df4a..fdc831fc 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -122,6 +122,9 @@ module DevelopmentSeed { key: "image-resizing", author: "hampton", type: "General", title: "Images: resize and snap to full width", tags: %w[demo images editor], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "Editor corner resizing, text wrapping beside images, Enter to continue below, undo, and snap to natural full width" }, + { key: "summary-block", author: "hampton", type: "Research Note", title: "Analysis: why saving long plans got slower", + tags: %w[demo markdown reading], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", + showcase: "A long analysis that opens with a summary block: a labeled card, a pinned Summary entry in the outline, comments on its text, and the preview text link unfurls use" }, { key: "math-gallery", author: "hampton", type: "Research Note", title: "Math: estimating a retry budget", tags: %w[demo markdown math], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "LaTeX math typeset with KaTeX: inline, display, a math fence, math in lists and tables, dollar amounts that stay text, and invalid math shown as source" }, diff --git a/spec/helpers/plans_helper_spec.rb b/spec/helpers/plans_helper_spec.rb index cea91907..4b93ff35 100644 --- a/spec/helpers/plans_helper_spec.rb +++ b/spec/helpers/plans_helper_spec.rb @@ -13,14 +13,14 @@ end end - describe "#plan_content_preview" do - let(:plan) { create(:plan, :considering) } + describe "#plan_row_summary" do + let(:plan) { create(:plan, :considering, summary: nil) } it "strips markdown formatting and returns a plain-text preview" do set_version_fixture(plan.current_plan_version, content_markdown: "# Heading\n\nA **bold** intro with [a link](https://example.com)." ) - preview = helper.plan_content_preview(plan) + preview = helper.plan_row_summary(plan) expect(preview).to include("Heading") expect(preview).to include("A bold intro with a link") expect(preview).not_to include("**") @@ -30,7 +30,7 @@ it "omits presentation delimiters from a mixed plan preview" do set_version_fixture(plan.current_plan_version, content_markdown: "::: {.presentation}\n\n# Opening slide\n\n:::\n\nClosing context.") - preview = helper.plan_content_preview(plan) + preview = helper.plan_row_summary(plan) expect(preview).to include("Opening slide", "Closing context") expect(preview).not_to include(":::", "presentation") @@ -38,21 +38,59 @@ it "truncates to the requested limit" do set_version_fixture(plan.current_plan_version, content_markdown: "word " * 100) - preview = helper.plan_content_preview(plan, limit: 40) + preview = helper.plan_row_summary(plan, limit: 40) expect(preview.length).to be <= 41 # 40 chars + ellipsis expect(preview).to end_with("…") end + it "prefers the summary block, then the generated summary, then the content" do + plan.update!(summary: "Generated summary") + set_version_fixture(plan.current_plan_version, + content_markdown: "# Plan\n\n::: {.summary}\n- **Ship** the queue option.\n:::\n\nBody text.") + expect(helper.plan_row_summary(plan.reload)).to eq("Ship the queue option.") + + set_version_fixture(plan.current_plan_version, content_markdown: "# Plan\n\nBody text.") + expect(helper.plan_row_summary(plan.reload)).to eq("Generated summary") + + plan.update!(summary: nil) + set_version_fixture(plan.current_plan_version, content_markdown: "# Plan\n\n::: {.summary}\n- Takeaway\n:::\n\nBody text.") + preview = helper.plan_row_summary(plan.reload) + expect(preview).to eq("Takeaway") + end + + it "does not parse whole-plan text when an authored or generated summary wins" do + plan.update!(summary: "Generated summary") + set_version_fixture(plan.current_plan_version, content_markdown: "# Plan\n\nBody text.") + expect(helper).not_to receive(:markdown_to_plain_text) + expect(helper.plan_row_summary(plan.reload)).to eq("Generated summary") + + set_version_fixture(plan.current_plan_version, content_markdown: "::: {.summary}\nAuthored summary\n:::") + expect(helper.plan_row_summary(plan.reload)).to eq("Authored summary") + end + + it "caches extracted text without caching the generated summary decision" do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + set_version_fixture(plan.current_plan_version, content_markdown: "# Plan\n\nFallback body.") + plan.reload.update!(summary: "Generated summary") + expect(helper).to receive(:markdown_to_plain_text).once.and_call_original + expect(helper.plan_row_summary(plan)).to eq("Generated summary") + plan.update!(summary: nil) + expect(helper.plan_row_summary(plan)).to include("Fallback body.") + expect(helper.plan_row_summary(plan)).to include("Fallback body.") + plan.update!(summary: "New generated summary") + expect(helper.plan_row_summary(plan)).to eq("New generated summary") + end + it "returns nil when the plan has no content" do set_version_fixture(plan.current_plan_version, content_markdown: "", content_sha256: Digest::SHA256.hexdigest("")) plan.reload - expect(helper.plan_content_preview(plan)).to be_nil + expect(helper.plan_row_summary(plan)).to be_nil end it "returns nil when the plan has no version" do plan.update_columns(current_plan_version_id: nil) plan.reload - expect(helper.plan_content_preview(plan)).to be_nil + expect(helper.plan_row_summary(plan)).to be_nil end end end diff --git a/spec/helpers/summary_blocks_helper_spec.rb b/spec/helpers/summary_blocks_helper_spec.rb new file mode 100644 index 00000000..8e859abc --- /dev/null +++ b/spec/helpers/summary_blocks_helper_spec.rb @@ -0,0 +1,142 @@ +require "rails_helper" + +RSpec.describe CoPlan::SummaryBlocksHelper, type: :helper do + before { helper.extend(CoPlan::SlideshowsHelper) } + + def render(source, **options) + Nokogiri::HTML::DocumentFragment.parse(helper.render_content_regions(source, **options)) + end + + let(:source) do + <<~MD + # Plan + + ::: {.summary} + - **Ship** the queue option.[^why] + - See [the dashboard](https://example.com/d). + ::: + + ## 1 Details + + Body text. + + [^why]: Measured on staging. + MD + end + + [ true, false ].each do |interactive| + it "wraps the block in a summary card and drops its markers (interactive: #{interactive})" do + doc = render(source, interactive:) + card = doc.at_css("section.plan-summary") + + expect(card["id"]).to eq("plan-summary") + expect(card["aria-label"]).to eq("Summary") + expect(card.css("li").map { |item| item.text.squish }).to eq([ "Ship the queue option.1", "See the dashboard." ]) + expect(card.at_css("strong").text).to eq("Ship") + expect(doc.text).not_to include(":::", "{.summary}") + expect(doc.css("hr")).to be_empty + expect(doc.at_css("h1").next_element).to eq(card) + expect(card.next_element.name).to eq("h2") + expect(doc.at_css("h2")["id"]).to eq("section-1") + expect(doc.css("[data-sourcepos]")).to be_empty + end + end + + [ true, false ].each do |interactive| + it "typesets math inside and after a summary (interactive: #{interactive})" do + doc = render("::: {.summary}\nEstimate $p^n$.\n\n```math\nx^2\n```\n:::\nAfter $q^n$.", interactive:) + + expect(doc.css('section.plan-summary [data-controller="coplan--math"]').map(&:text)).to eq([ "p^n", "x^2\n" ]) + expect(doc.css('[data-controller="coplan--math"]').size).to eq(3) + end + end + + it "keeps a footnote citation inside the card linked to its definition" do + citation = render(source).at_css("section.plan-summary sup.footnote-ref a") + + expect(citation["href"]).to eq("#fn-why") + expect(citation.classes).to include("reference-anchor--footnote") + footnotes = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown(source, footnotes: :only)) + expect(footnotes.at_css("#fn-why").text).to include("Measured on staging.") + end + + it "renders a second block as plain content" do + doc = render("::: {.summary}\nFirst\n:::\n\n::: {.summary}\nSecond\n:::") + + expect(doc.css("section.plan-summary").size).to eq(1) + expect(doc.at_css("section.plan-summary").text).to include("First") + expect(doc.at_css("section.plan-summary").text).not_to include("Second") + expect(doc.text).to include("::: {.summary}", "Second", ":::") + end + + it "does not treat author HTML as the summary card" do + doc = render(%(
Forged
\n\nBody)) + + expect(doc.css(".plan-summary, #plan-summary")).to be_empty + expect(doc.at_css("section.note").text).to eq("Forged") + end + + it "keeps the real card when the author also forges one" do + doc = render(%(
Forged
\n\n#{source})) + + expect(doc.css("section.plan-summary").size).to eq(1) + expect(doc.at_css("section.plan-summary").text).not_to include("Forged") + end + + it "leaves an unclosed marker and a marker in code as text" do + doc = render("```markdown\n::: {.summary}\n- Example\n:::\n```\n\n::: {.summary}\nUnclosed") + + expect(doc.css("section.plan-summary")).to be_empty + expect(doc.at_css("pre code").text).to include("::: {.summary}") + expect(doc.at_css("p").text).to include("::: {.summary}") + end + + it "renders the summary in a plan that also has a deck" do + doc = render("::: {.summary}\n- Short version\n:::\n\n::: {.presentation}\n\n# Slide\n\n:::\n\nAfter") + + expect(doc.at_css("section.plan-summary").text.squish).to eq("Short version") + expect(doc.css(".deck-region .deck-slide").size).to eq(1) + end + + it "keeps table comment targets inside the card resolvable" do + content = "::: {.summary}\n| Option | Cost |\n|---|---|\n| Queue | Low |\n:::\n\nAfter" + doc = render(content) + targets = doc.css("section.plan-summary [data-source-target]").map { |node| JSON.parse(node["data-source-target"]) } + + expect(targets.size).to eq(4) + targets.each do |target| + resolved = CoPlan::Plans::SourceTargets.resolve(target["token"], content) + expect(content[resolved["start"]...resolved["end"]].strip).to eq(target["text"].strip) + expect(CoPlan::Plans::SourceTargets.new(content).ranges).to include(resolved.values_at("kind", "start", "end")) + end + end + + it "renders recognized summary markers inside an HTML container without stray rules" do + content = "
\nRead more\n\n::: {.summary}\nNested takeaway\n:::\n\n
" + expect(CoPlan::ContentRegions::Split.summary_text(content)).to eq("Nested takeaway") + doc = render(content) + expect(doc.at_css("details section.plan-summary")&.text&.strip).to eq("Nested takeaway") + expect(doc.css("hr")).to be_empty + end + + it "preserves existing heading fragments when assigning the summary card id" do + doc = render("#{source}\n\n## Plan summary\n\nExisting section.\n\n## Plan summary 2") + heading = doc.css("h2").find { |node| node.text.strip == "Plan summary" } + expect(heading.at_css("a.anchor")["id"]).to eq("plan-summary") + expect(doc.at_css("section.plan-summary")["id"]).not_to eq("plan-summary") + ids = doc.css("[id]").map { |node| node["id"] } + expect(ids.uniq).to eq(ids) + end + + it "preserves heading fragments across deck and document boundaries" do + doc = render("#{source}\n\n::: {.presentation}\n\n# Slide\n\n:::\n\n## Plan summary\n\nExisting section.") + expect(doc.at_css("section.plan-summary")["id"]).not_to eq("plan-summary") + expect(doc.at_css("h2 a#plan-summary")).to be_present + end + + it "does not render a card outside a plan document" do + doc = Nokogiri::HTML::DocumentFragment.parse(helper.render_markdown("::: {.summary}\nComment text\n:::")) + + expect(doc.css("section.plan-summary")).to be_empty + end +end diff --git a/spec/jobs/slack_unfurl_job_spec.rb b/spec/jobs/slack_unfurl_job_spec.rb index b252b50d..99f3fe11 100644 --- a/spec/jobs/slack_unfurl_job_spec.rb +++ b/spec/jobs/slack_unfurl_job_spec.rb @@ -50,6 +50,19 @@ "channel" => "C1", "message_ts" => "123.4", "links" => [ { "url" => exact } ]) end + it "describes the plan with its summary block rather than the generated summary" do + plan.update!(summary: "Generated summary") + set_version_fixture(plan.current_plan_version, content_markdown: "# Plan\n\n::: {.summary}\n- Ship the queue option.\n:::\n\nBody.") + exact = "https://coplan.example.test/plans/#{plan.id}" + expect(client).to receive(:chat_unfurl) do |unfurls:, **| + text = unfurls.fetch(exact).dig(:blocks, 0, :text, :text) + expect(text).to include("Ship the queue option.") + expect(text).not_to include("Generated summary") + end + + described_class.perform_now("channel" => "C1", "message_ts" => "123.4", "links" => [ { "url" => exact } ]) + end + it "does not call Slack when every link is unsupported" do expect(client).not_to receive(:chat_unfurl) described_class.perform_now("links" => [ { "url" => "https://elsewhere.test/x" } ]) diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index b7e4eae8..ee572158 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -322,6 +322,27 @@ def seeded_plan(key) expect(plan.reload.current_content).to eq(resized) end + it "seeds a long analysis that opens with one summary block and preserves edits on re-seed" do + described_class.call + plan = seeded_plan("summary-block") + content = plan.current_content + expect(CoPlan::ContentRegions::Split.call(content).summary).to be_present + expect(content.lines.count { |line| line.chomp == "::: {.summary}" }).to eq(1) + expect(content).to include("[^profile]:", "[§5 Options](#section-5)", "```mermaid", "| Plan size |") + expect(content.scan(/^## /).size).to be >= 6 + expect(CoPlan::LinkPreviews.for_plan(plan, base_url: "http://localhost:3000").description).to start_with("Recommendation: move search indexing") + expect(seeded_plan("demo-library").current_content).to include("(/#{plan.url_path})", "`summary-block`") + revision = plan.current_revision + described_class.call + expect(plan.reload.current_revision).to eq(revision) + + edited = content.sub("Decision needed:", "Decision made:") + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: edited, + base_revision: plan.current_revision, actor_type: "human", actor_id: plan.created_by_user_id) + described_class.call + expect(plan.reload.current_content).to eq(edited) + end + it "seeds the math gallery with every math form and preserves edits on re-seed" do described_class.call plan = seeded_plan("math-gallery") @@ -356,6 +377,7 @@ def seeded_plan(key) described_class.call plan = seeded_plan("pattern-showcase") legacy = plan.current_content.gsub(described_class.demo_logo_url, "/coplan-logo.png") + CoPlan::Plans::ReplaceContent.call(plan: plan, new_content: legacy, base_revision: plan.current_revision, actor_type: "system", actor_id: plan.created_by_user_id) described_class.call diff --git a/spec/models/comment_thread_anchor_spec.rb b/spec/models/comment_thread_anchor_spec.rb index 54df72ae..02ec0b73 100644 --- a/spec/models/comment_thread_anchor_spec.rb +++ b/spec/models/comment_thread_anchor_spec.rb @@ -397,6 +397,24 @@ def assert_anchor_resolves(markdown, dom_text, expected_raw, occurrence: nil) end end + context "inside a summary block" do + let(:summary_markdown) { "# Plan\n\n::: {.summary}\n- The **summary** names one option.\n- Ship it.\n:::\n\nA second summary sentence." } + + it "never anchors to the block's marker lines" do + thread = assert_anchor_resolves(summary_markdown, "summary", "summary") + expect(thread.anchor_start).to eq(summary_markdown.index("**summary**") + 2) + expect(thread.anchor_occurrence_index).to eq(0) + + second = assert_anchor_resolves(summary_markdown, "summary", "summary", occurrence: 2) + expect(second.anchor_start).to eq(summary_markdown.index("summary sentence")) + end + + it "resolves a selection across formatting and list items in the card" do + assert_anchor_resolves(summary_markdown, "The summary names one option. Ship it.", + "The **summary** names one option.\n- Ship it.") + end + end + it "handles missing anchor_text gracefully" do thread = plan.comment_threads.create!( plan_version: plan.current_plan_version, diff --git a/spec/services/content_regions/split_spec.rb b/spec/services/content_regions/split_spec.rb index f1cfd0e3..adc3bf34 100644 --- a/spec/services/content_regions/split_spec.rb +++ b/spec/services/content_regions/split_spec.rb @@ -29,4 +29,41 @@ expect(result.regions.first.source).to eq("") expect(result.canonical_source).not_to include(":::") end + + describe "summary blocks" do + it "finds a summary whose closer follows a list without a blank line" do + source = "# Title\n\n::: {.summary}\n- One\n- Two\n:::\n\nBody" + result = described_class.call(source) + + expect(result.summary.to_h).to eq(open_line: 3, close_line: 6) + expect(result.regions.map(&:kind)).to eq([ :document ]) + expect(result.canonical_source.lines.map(&:length)).to eq(source.lines.map(&:length)) + expect(result.canonical_source).not_to include("{.summary}", ":::") + end + + it "keeps only the first block and ignores markers in code, quotes, and raw HTML" do + source = "```text\n::: {.summary}\n:::\n```\n\n> ::: {.summary}\n\n
\n::: {.summary}\n
\n\n" \ + "::: {.summary}\nFirst\n:::\n\n::: {.summary}\nSecond\n:::" + result = described_class.call(source) + + expect(result.summary.to_h).to eq(open_line: 12, close_line: 14) + expect(result.canonical_source).to include("::: {.summary}\nSecond\n:::") + end + + it "leaves an unclosed summary and one interrupted by a deck as text" do + expect(described_class.call("::: {.summary}\n- One").summary).to be_nil + + result = described_class.call("::: {.summary}\n\n::: {.presentation}\n\n# Slide\n\n:::\n\nAfter") + expect(result.summary).to be_nil + expect(result.regions.map(&:kind)).to eq(%i[document presentation document]) + end + + it "extracts the block's reading text without citation markers" do + source = "::: {.summary}\n- **Ship** it with [the flag](https://example.com).[^why]\n- `Two`\n:::\n\nBody text.\n\n[^why]: Because." + + expect(described_class.summary_text(source)).to eq("Ship it with the flag. Two") + expect(described_class.summary_text("No summary here.")).to be_nil + expect(described_class.summary_text("::: {.summary}\n:::")).to be_nil + end + end end diff --git a/spec/services/link_previews_spec.rb b/spec/services/link_previews_spec.rb index c89e9848..df7dea27 100644 --- a/spec/services/link_previews_spec.rb +++ b/spec/services/link_previews_spec.rb @@ -160,6 +160,28 @@ expect(described_class.for_plan(plan.reload, base_url: base_url).cache_key).not_to eq(first.cache_key) end + it "prefers the summary block over the generated summary, truncated the same way" do + plan.update!(summary: "Generated summary") + set_version_fixture(plan.current_plan_version, + content_markdown: "# Plan\n\n::: {.summary}\n- **Ship** the queue option.[^why]\n- Saves get faster.\n:::\n\nBody.\n\n[^why]: Profiled.") + expect(described_class.for_plan(plan.reload, base_url: base_url).description).to eq("Ship the queue option. Saves get faster.") + + set_version_fixture(plan.current_plan_version, content_markdown: "::: {.summary}\n#{"takeaway " * 60}\n:::") + long = described_class.for_plan(plan.reload, base_url: base_url).description + expect(long.length).to eq(240) + expect(long).to end_with("…") + end + + it "falls back to the generated summary, then content, when there is no summary block" do + plan.update!(summary: "Generated summary") + set_version_fixture(plan.current_plan_version, content_markdown: "```markdown\n::: {.summary}\nExample\n:::\n```\n\nBody.") + expect(described_class.for_plan(plan.reload, base_url: base_url).description).to eq("Generated summary") + + plan.update!(summary: nil) + set_version_fixture(plan.current_plan_version, content_markdown: "::: {.summary}\n:::\n\nOpening text.") + expect(described_class.for_plan(plan.reload, base_url: base_url).description).to eq("Opening text.") + end + it "changes its cache key when preview metadata changes" do first = described_class.for_plan(plan, base_url: base_url) retitle_plan(plan, "A new title") diff --git a/spec/system/summary_block_spec.rb b/spec/system/summary_block_spec.rb new file mode 100644 index 00000000..ead46cf8 --- /dev/null +++ b/spec/system/summary_block_spec.rb @@ -0,0 +1,181 @@ +require "rails_helper" + +RSpec.describe "Summary block", type: :system do + let(:author) { create(:coplan_user, email: "summary-block@example.com") } + let(:filler) { (1..12).map { |n| "Supporting detail paragraph #{n} that a leader can skip on a first read." }.join("\n\n") } + let(:content) do + <<~MD + # Save latency analysis + + ::: {.summary} + - **Recommendation:** index plans on the job queue. + - Saves get about 300 ms faster for long plans.[^profile] + ::: + + ## 1 Background + + #{filler} + + ## 2 Options + + #{filler} + + [^profile]: Measured on staging. + MD + end + let(:plan) do + CoPlan::Plans::Create.call(title: "Save latency analysis", content: content, user: author, + visibility: "published", actor_type: "human") + end + + before do + visit sign_in_path + fill_in "Email address", with: author.email + click_button "Sign In" + expect(page).to have_current_path(root_path) + end + + it "shows the summary as a labeled card with a pinned outline entry that scrolls to it" do + visit plan_page_path(plan) + + card = find("#plan-content-body section.plan-summary") + expect(card).to have_css("li", text: "Recommendation: index plans on the job queue.") + expect(card).to have_css("sup.footnote-ref a", text: "1") + expect(page).to have_no_text("::: {.summary}") + label = page.evaluate_script("getComputedStyle(document.querySelector('section.plan-summary'), '::before').content") + expect(label).to include("Summary") + expect(page.evaluate_script("getComputedStyle(document.querySelector('section.plan-summary')).borderLeftStyle")).to eq("solid") + + items = all(".content-nav__list .content-nav__item") + expect(items.first[:class]).to include("content-nav__item--summary") + expect(items.first).to have_link("Summary", href: "#plan-summary") + + page.execute_script("window.scrollTo(0, document.body.scrollHeight)") + expect(page.evaluate_script("document.getElementById('plan-summary').getBoundingClientRect().top")).to be < 0 + find(".content-nav__item--summary a").click + page.document.synchronize(5) do + top = page.evaluate_script("document.getElementById('plan-summary').getBoundingClientRect().top") + raise Capybara::ExpectationNotMet, "summary card is at #{top}px" unless top.between?(0, 200) + end + expect(page).to have_css(".content-nav__item--summary .content-nav__link--active") + expect(page).to have_current_path(/#plan-summary\z/, url: true) + end + + it "starts a comment from a selection inside the card" do + visit plan_page_path(plan) + + page.execute_script <<~JS + const item = document.querySelector('section.plan-summary li'); + const walker = document.createTreeWalker(item, NodeFilter.SHOW_TEXT); + let node; + while ((node = walker.nextNode()) && !node.textContent.includes('index plans')) {} + const start = node.textContent.indexOf('index plans'); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + 'index plans on the job queue'.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + item.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + JS + expect(page).to have_css(".comment-popover", visible: true, wait: 3) + page.driver.browser.action.send_keys("c").perform + + within("#new-comment-form") do + textarea = find("textarea:focus") + textarea.fill_in with: "Which queue?" + textarea.send_keys(:enter) + end + + expect(page).to have_css("section.plan-summary mark.anchor-highlight", text: "index plans on the job queue", wait: 5) + thread = plan.comment_threads.reload.order(:created_at).last + expect(thread.anchor_text).to eq("index plans on the job queue") + expect(content[thread.anchor_start...thread.anchor_end]).to eq("index plans on the job queue") + expect(thread.comments.first.body_markdown).to eq("Which queue?") + end + + it "keeps the block as one Summary card in the rich editor and saves it unchanged" do + page.driver.browser.manage.timeouts.script_timeout = 20 + result = page.evaluate_async_script(<<~'JS') + const done = arguments[0]; + import("coplan/rich_document").then(m => { + const examples = [ + "# Plan\n\n::: {.summary}\n- One\n- Two\n:::\n\nBody\n", + "Intro\n::: {.summary}\nText\n:::\nAfter\n", + "```markdown\n::: {.summary}\n:::\n```\n", + "::: {.summary}\nFirst\n:::\n\n::: {.summary}\nSecond\n:::\n", + "
\n::: {.summary}\nExample\n:::\n
\n", + "\n\n::: {.summary}\nReal summary\n:::\n", + "::: {.summary}\nIntro\n\n\n\nTail\n:::\n" + ]; + done(examples.map(source => { + const doc = m.parseDocument(source), kinds = [], summarySources = []; + doc.forEach(node => { + kinds.push(node.attrs.kind || node.type.name); + if (node.attrs.kind === "summary") summarySources.push(node.attrs.source); + }); + return { summarySources, exact: m.serializeDocument(doc) === source, summaries: kinds.filter(kind => kind === "summary").length }; + })); + }).catch(error => done({ error: error.message })); + JS + expect(result).to all(include("exact" => true)) + expect(result.map { |entry| entry.fetch("summaries") }).to eq([ 1, 1, 0, 1, 0, 1, 1 ]) + expect(result.last.fetch("summarySources").first).to end_with("Tail\n:::\n") + + visit plan_edit_page_path(plan) + expect(page).to have_no_css(".inline-editor--preparing", wait: 20) + card = find(".document-editor__block", text: "Summary", match: :first) + expect(card).to have_css(".document-editor__block-preview section.plan-summary li", text: "Saves get about 300 ms faster", wait: 10) + expect(card).to have_css(".document-editor__block-preview sup.footnote-ref", text: "1") + expect(card).to have_no_css("[id='plan-summary']") + expect(page).to have_no_css(".ProseMirror p", text: "::: {.summary}") + + paragraph = find(".ProseMirror p", text: "Supporting detail paragraph 1 ", match: :first) + paragraph.click + paragraph.send_keys(:end, " Reviewed.") + expect(page).to have_css('[data-coplan--editor-target="statusText"]', text: "All changes saved · v2", visible: :all, wait: 15) + summary_source = content[/::: \{\.summary\}.*?:::/m] + expect(plan.reload.current_content).to include(summary_source, "Reviewed.") + click_button "Done editing" + expect(page).to have_css("#plan-content-body section.plan-summary", text: "Recommendation:") + expect(plan.reload.current_content).to include(summary_source) + end + + it "refreshes citations and renders diagrams inside the summary preview" do + enriched = content.sub("::: {.summary}", "Earlier citation.[^earlier]\n\n::: {.summary}") + .sub(":::\n\n## 1 Background", "\n```mermaid\nflowchart LR\nA[Queue] --> B[Search]\n```\n\n:::\n\n## 1 Background") + enriched += "\n[^earlier]: Earlier reference.\n" + plan = CoPlan::Plans::Create.call(title: "Save latency analysis", content: enriched, user: author, + visibility: "published", actor_type: "human") + visit plan_edit_page_path(plan) + card = find(".document-editor__block", text: "Summary", match: :first) + expect(card).to have_css("sup.footnote-ref", text: "2", wait: 15) + expect(card).to have_css(".mermaid-diagram svg", wait: 20) + paragraph = find(".ProseMirror p", text: "Earlier citation.") + page.execute_script(<<~JS, paragraph) + const range = document.createRange(); + range.selectNodeContents(arguments[0]); + const selection = window.getSelection(); + selection.removeAllRanges(); selection.addRange(range); + JS + page.driver.browser.action.send_keys(:backspace).perform + expect(card).to have_css("sup.footnote-ref", text: "1", wait: 15) + end + + it "navigates nested summaries without taking an existing heading fragment" do + nested = content.sub("::: {.summary}", "
\nExecutive overview\n\n::: {.summary}") + .sub(":::\n\n## 1 Background", ":::\n\n
\n\n## 1 Background") + nested += "\n## Plan summary\n\nExisting section link.\n" + nested_plan = CoPlan::Plans::Create.call(title: "Save latency analysis", content: nested, user: author, + visibility: "published", actor_type: "human") + visit plan_page_path(nested_plan) + expect(page).to have_css("details section.plan-summary", text: "Recommendation:") + expect(page).to have_css("h2#plan-summary", text: "Plan summary") + expect(page).to have_no_css("details hr") + click_link "Plan summary", exact: true + expect(page).to have_current_path(/#plan-summary\z/, url: true) + find(".content-nav__item--summary a").click + expect(page).to have_current_path(/#plan-summary-2\z/, url: true) + expect(page).to have_css(".content-nav__item--summary .content-nav__link--active") + end +end