From 973db39b59034fc820d868419c78fd2426eb696d Mon Sep 17 00:00:00 2001 From: ahdoot Date: Sat, 31 Oct 2026 04:32:38 +0000 Subject: [PATCH 1/9] Add link shortcuts to the comment composer Mod+K in a comment, reply or edit textarea prompts for a URL with the document editor's prompt and validation, then writes [selection](url), or [](url) with the caret between the brackets when nothing is selected. Pasting a single http(s) or mailto URL over a selection links it. Edits go through insertText so Cmd+Z undoes them and input listeners (mentions, dictation) stay in sync. Both appear in the shortcut help. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../coplan/comment_form_controller.js | 35 +++++++- .../comment_threads/_composer_fields.html.erb | 1 + engine/config/keyboard_shortcuts.json | 4 +- spec/system/comment_composer_links_spec.rb | 80 +++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 spec/system/comment_composer_links_spec.rb diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index 966db018..ebf12f0b 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -1,10 +1,13 @@ import { Controller } from "@hotwired/stimulus" import { commandFor } from "coplan/shortcuts" +const LINK_URL = /^(https?:\/\/|mailto:)/i + // Comment-form behavior: submit-on-Enter (Shift+Enter for newline) plus an // inline @-mention picker. The picker activates when the user types `@` at a // word boundary inside the textarea, fetches matches from /users/search, and -// inserts `[@username](mention:username)` markdown on selection. +// inserts `[@username](mention:username)` markdown on selection. Mod+K and +// pasting a URL over a selection write Markdown links. // // Both behaviors live on the same controller so submit-on-Enter can defer to // the picker when it's open (Enter selects the highlighted user instead of @@ -101,6 +104,36 @@ export default class extends Controller { } } + // Mod+K matches the document editor's link command, writing Markdown. + link(event) { + if (commandFor("reply", event) !== "link") return + event.preventDefault() + const { selectionStart: start, selectionEnd: end } = this.element + const href = window.prompt("Link URL (https://…)")?.trim() + if (!href || !LINK_URL.test(href)) return + this.element.focus() + this.element.setSelectionRange(start, end) + const label = this.element.value.slice(start, end) + this.insertText(`[${label}](${href})`) + if (!label) this.element.setSelectionRange(start + 1, start + 1) + } + + pasteLink(event) { + const { selectionStart: start, selectionEnd: end, value } = this.element + const href = event.clipboardData?.getData("text/plain").trim() + const label = value.slice(start, end) + if (!label || !LINK_URL.test(href) || /\s/.test(href) || LINK_URL.test(label.trim())) return + event.preventDefault() + this.insertText(`[${label}](${href})`) + } + + // insertText keeps the edit on the browser's undo stack and fires input. + insertText(text) { + if (document.execCommand("insertText", false, text)) return + this.element.setRangeText(text, this.element.selectionStart, this.element.selectionEnd, "end") + this.element.dispatchEvent(new Event("input", { bubbles: true })) + } + handleInput(_event) { const trigger = this.detectMentionTrigger() if (!trigger) { diff --git a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb index 1352677f..1ab6f360 100644 --- a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb +++ b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb @@ -10,6 +10,7 @@ rows: 3, placeholder: local_assigns.fetch(:placeholder, "Write a comment..."), required: true, aria: { label: label }, class: "composer__input", data: { controller: "coplan--comment-form", "coplan--dictation-target": "input", + action: "keydown->coplan--comment-form#link paste->coplan--comment-form#pasteLink", "coplan--comment-form-search-url-value": search_users_path }.merge(local_assigns.fetch(:textarea_data, {})) %>
diff --git a/engine/config/keyboard_shortcuts.json b/engine/config/keyboard_shortcuts.json index 48c5968d..ef529630 100644 --- a/engine/config/keyboard_shortcuts.json +++ b/engine/config/keyboard_shortcuts.json @@ -79,7 +79,9 @@ "local": true, "bindings": [ {"id":"submit","keys":["Enter"],"description":"Submit a comment or reply"}, - {"id":"newline","keys":["Shift+Enter"],"description":"Add a line to a comment","native":true} + {"id":"newline","keys":["Shift+Enter"],"description":"Add a line to a comment","native":true}, + {"id":"link","keys":["Mod+k"],"description":"Link the selected text, or insert a link"}, + {"id":"pasteLink","keys":["Mod+v"],"description":"Paste a URL over selected text to link it","native":true} ] }, "editor": { diff --git a/spec/system/comment_composer_links_spec.rb b/spec/system/comment_composer_links_spec.rb new file mode 100644 index 00000000..3d61ff5e --- /dev/null +++ b/spec/system/comment_composer_links_spec.rb @@ -0,0 +1,80 @@ +require "rails_helper" + +RSpec.describe "Links in the comment composer", type: :system do + let(:author) { create(:coplan_user, email: "links@example.com") } + let(:plan) { create(:plan, :considering, created_by_user: author) } + let!(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: author) } + let!(:comment) { create(:comment, comment_thread: thread_record, author_id: author.id, body_markdown: "Start here") } + let(:textarea) { find(".comment-window:popover-open textarea") } + + before do + allow(CoPlan::Ai).to receive(:available?).and_return(false) + visit sign_in_path + fill_in "Email address", with: author.email + click_button "Sign In" + expect(page).to have_current_path("/links") + visit plan_page_path(plan) + expand_plan_section("plan-general-comments") + within("#plan-general-comments") { click_button "Open discussion", enable_aria_label: true } + textarea.fill_in with: "See Design Overview" + end + + def select_text(start, finish) + page.execute_script("arguments[0].setSelectionRange(arguments[1], arguments[2])", textarea, start, finish) + end + + def paste(text) + page.evaluate_script(<<~JS, textarea, text) + (() => { + const data = new DataTransfer() + data.setData("text/plain", arguments[1]) + return arguments[0].dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true })) + })() + JS + end + + def caret + page.evaluate_script("[arguments[0].selectionStart, arguments[0].selectionEnd]", textarea) + end + + it "links the selection with Mod+K, undoes it, and posts it as a link" do + select_text(4, 19) + accept_prompt(with: "https://example.com") { textarea.send_keys([ :control, "k" ]) } + expect(textarea.value).to eq("See [Design Overview](https://example.com)") + textarea.send_keys([ :control, "z" ]) + expect(textarea.value).to eq("See Design Overview") + textarea.send_keys([ :control, "y" ]) + textarea.send_keys(:enter) + expect(page).to have_link("Design Overview", href: "https://example.com") + expect(CoPlan::Comment.last.body_markdown).to eq("See [Design Overview](https://example.com)") + end + + it "inserts an empty link at the caret without a selection" do + select_text(3, 3) + accept_prompt(with: "mailto:team@example.com") { textarea.send_keys([ :control, "k" ]) } + expect(textarea.value).to eq("See[](mailto:team@example.com) Design Overview") + expect(caret).to eq([ 4, 4 ]) + end + + it "leaves the draft alone when the prompt is cancelled or the URL is invalid" do + select_text(4, 19) + dismiss_prompt { textarea.send_keys([ :control, "k" ]) } + accept_prompt(with: "javascript:alert(1)") { textarea.send_keys([ :control, "k" ]) } + expect(textarea.value).to eq("See Design Overview") + end + + it "links a selection when a URL is pasted over it and leaves other pastes alone" do + select_text(4, 19) + expect(paste(" https://example.com/design \n")).to be(false) + expect(textarea.value).to eq("See [Design Overview](https://example.com/design)") + + textarea.fill_in with: "See Design Overview" + select_text(4, 19) + expect(paste("just some words")).to be(true) + select_text(19, 19) + expect(paste("https://example.com")).to be(true) + textarea.fill_in with: "https://example.com" + select_text(0, 19) + expect(paste("https://example.org")).to be(true) + end +end From 32c8537928e343e518d7f77a256671ec27be8fbc Mon Sep 17 00:00:00 2001 From: ahdoot Date: Sat, 31 Oct 2026 05:51:03 +0000 Subject: [PATCH 2/9] Use the platform modifier in the composer link spec Ctrl+Z and Ctrl+Y don't undo or redo in Chrome on macOS, so the spec would fail on Mac development machines. Use Cmd there, like the other editor specs, and Mod+Shift+Z for redo on both platforms. Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/system/comment_composer_links_spec.rb | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/spec/system/comment_composer_links_spec.rb b/spec/system/comment_composer_links_spec.rb index 3d61ff5e..5eec8455 100644 --- a/spec/system/comment_composer_links_spec.rb +++ b/spec/system/comment_composer_links_spec.rb @@ -6,6 +6,7 @@ let!(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: author) } let!(:comment) { create(:comment, comment_thread: thread_record, author_id: author.id, body_markdown: "Start here") } let(:textarea) { find(".comment-window:popover-open textarea") } + let(:mod) { RUBY_PLATFORM.include?("darwin") ? :meta : :control } before do allow(CoPlan::Ai).to receive(:available?).and_return(false) @@ -39,11 +40,11 @@ def caret it "links the selection with Mod+K, undoes it, and posts it as a link" do select_text(4, 19) - accept_prompt(with: "https://example.com") { textarea.send_keys([ :control, "k" ]) } + accept_prompt(with: "https://example.com") { textarea.send_keys([ mod, "k" ]) } expect(textarea.value).to eq("See [Design Overview](https://example.com)") - textarea.send_keys([ :control, "z" ]) + textarea.send_keys([ mod, "z" ]) expect(textarea.value).to eq("See Design Overview") - textarea.send_keys([ :control, "y" ]) + textarea.send_keys([ mod, :shift, "z" ]) textarea.send_keys(:enter) expect(page).to have_link("Design Overview", href: "https://example.com") expect(CoPlan::Comment.last.body_markdown).to eq("See [Design Overview](https://example.com)") @@ -51,15 +52,15 @@ def caret it "inserts an empty link at the caret without a selection" do select_text(3, 3) - accept_prompt(with: "mailto:team@example.com") { textarea.send_keys([ :control, "k" ]) } + accept_prompt(with: "mailto:team@example.com") { textarea.send_keys([ mod, "k" ]) } expect(textarea.value).to eq("See[](mailto:team@example.com) Design Overview") expect(caret).to eq([ 4, 4 ]) end it "leaves the draft alone when the prompt is cancelled or the URL is invalid" do select_text(4, 19) - dismiss_prompt { textarea.send_keys([ :control, "k" ]) } - accept_prompt(with: "javascript:alert(1)") { textarea.send_keys([ :control, "k" ]) } + dismiss_prompt { textarea.send_keys([ mod, "k" ]) } + accept_prompt(with: "javascript:alert(1)") { textarea.send_keys([ mod, "k" ]) } expect(textarea.value).to eq("See Design Overview") end From 2758b3d86ac1de82b7f6af6af498b681f89283de Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 5 Nov 2026 13:11:55 -0600 Subject: [PATCH 3/9] Escape comment composer link delimiters Signed-off-by: Hampton Lintorn-Catlin --- .../coplan/comment_form_controller.js | 12 +++++++-- spec/system/comment_composer_links_spec.rb | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index ebf12f0b..cbe1ea10 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -114,7 +114,7 @@ export default class extends Controller { this.element.focus() this.element.setSelectionRange(start, end) const label = this.element.value.slice(start, end) - this.insertText(`[${label}](${href})`) + this.insertLink(label, href) if (!label) this.element.setSelectionRange(start + 1, start + 1) } @@ -124,7 +124,15 @@ export default class extends Controller { const label = value.slice(start, end) if (!label || !LINK_URL.test(href) || /\s/.test(href) || LINK_URL.test(label.trim())) return event.preventDefault() - this.insertText(`[${label}](${href})`) + this.insertLink(label, href) + } + + insertLink(label, href) { + // Escape backslashes together with delimiters so literal prose and URL + // punctuation survive CommonMark parsing when the comment is posted. + const escapedLabel = label.replace(/[\\\[\]]/g, "\\$&") + const escapedHref = href.replace(/[\\()]/g, "\\$&") + this.insertText(`[${escapedLabel}](${escapedHref})`) } // insertText keeps the edit on the browser's undo stack and fires input. diff --git a/spec/system/comment_composer_links_spec.rb b/spec/system/comment_composer_links_spec.rb index 5eec8455..544515e6 100644 --- a/spec/system/comment_composer_links_spec.rb +++ b/spec/system/comment_composer_links_spec.rb @@ -50,6 +50,33 @@ def caret expect(CoPlan::Comment.last.body_markdown).to eq("See [Design Overview](https://example.com)") end + [ :keyboard, :paste ].each do |method| + [ + [ 'Use [draft', 'https://example.com/search?q=)' ], + [ 'design] notes', 'https://example.com/search?q=(' ], + [ 'Path \\[draft] (notes)', 'https://example.com/search?q=\\[draft](notes)' ] + ].each do |label, href| + it "preserves #{label.inspect} and #{href.inspect} in a rendered #{method} link" do + textarea.fill_in with: label + select_text(0, label.length) + if method == :keyboard + accept_prompt(with: href) { textarea.send_keys([ mod, "k" ]) } + else + expect(paste(href)).to be(false) + end + textarea.send_keys(:enter) + # CommonMark percent-encodes backslashes and brackets in destinations. + expected_href = href.gsub("\\", "%5C").gsub("[", "%5B").gsub("]", "%5D") + expect(page).to have_link(label, href: expected_href, exact: true) + posted = CoPlan::Comment.order(:created_at).last + html = Nokogiri::HTML.fragment(CoPlan::Markdown.to_html(posted.body_markdown)) + expect(html.css("a").size).to eq(1) + expect(html.at_css("a").text).to eq(label) + expect(html.at_css("a")["href"]).to eq(expected_href) + end + end + end + it "inserts an empty link at the caret without a selection" do select_text(3, 3) accept_prompt(with: "mailto:team@example.com") { textarea.send_keys([ mod, "k" ]) } From b3be8d3aea1ad3ac4108b84c65d084cc4493f60c Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 5 Nov 2026 15:45:25 -0600 Subject: [PATCH 4/9] Replace comment link prompt with in-app field Signed-off-by: Hampton Lintorn-Catlin --- .../app/assets/stylesheets/coplan/review.css | 10 +- .../coplan/comment_form_controller.js | 98 ++++++++++++++----- .../guides/comments.text.erb | 9 ++ .../comment_threads/_composer_fields.html.erb | 22 ++++- engine/db/demo/content/comment-links.md | 11 +++ engine/lib/coplan/development_seed.rb | 3 + spec/lib/development_seed_spec.rb | 4 + spec/system/comment_composer_links_spec.rb | 59 +++++++++-- 8 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 engine/db/demo/content/comment-links.md diff --git a/engine/app/assets/stylesheets/coplan/review.css b/engine/app/assets/stylesheets/coplan/review.css index 2813b383..27ad57d2 100644 --- a/engine/app/assets/stylesheets/coplan/review.css +++ b/engine/app/assets/stylesheets/coplan/review.css @@ -71,7 +71,14 @@ font-size: 12px; line-height: 1.5; max-height: 54px; overflow: auto; border-left: 2px solid var(--color-primary); background: var(--color-interaction-hover-bg); margin: 0 0 8px; } -.composer { min-width: 0; } +.composer { min-width: 0; position: relative; } +.composer__link { position: absolute; inset: 0; overflow: auto; } +.composer__link[hidden] { display: none; } +.composer__link label { display: block; font-size: 12px; color: var(--color-text-muted); } +.composer__link input { display: block; box-sizing: border-box; width: 100%; margin-top: 4px; padding: 8px 10px; border: 1px solid var(--color-border); border-radius: 9px; background: var(--color-surface); color: var(--color-text); font: 13px var(--font-sans); } +.composer__link input:focus { outline: 2px solid var(--color-primary); outline-offset: -1px; } +.composer__link .composer__send-actions { justify-content: flex-end; margin-top: 8px; } +.composer__link p { margin: 6px 0; color: var(--color-text-muted); } .composer__writing { display: flex; flex-direction: column; min-height: 0; } .composer .composer__input { width: 100%; min-height: 70px; padding: 9px 10px; margin: 0; @@ -101,6 +108,7 @@ .comment-window[data-resized] .thread-popover__reply .comment-thread__reply, .comment-window[data-resized] .thread-popover__reply form, .comment-window[data-resized] .composer, +.comment-window[data-resized] .composer__draft, .comment-window[data-resized] .composer__writing { display: flex; flex-direction: column; flex: 1; min-height: 0; } .comment-window[data-resized] { overflow: hidden; } .comment-window[data-resized] .composer__input { flex: 1; } diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index cbe1ea10..59f99f49 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -16,6 +16,8 @@ export default class extends Controller { // Search URL is supplied per-form via a Stimulus value so it respects the // engine's mount point (host apps may mount Coplan under e.g. `/coplan`). // The default is fine for hosts that mount at root. + static targets = ["input", "draft", "linkPanel", "linkUrl", "linkError"] + static values = { searchUrl: { type: String, default: "/users/search" } } @@ -31,23 +33,23 @@ export default class extends Controller { this._keydownHandler = this.handleKeydown.bind(this) this._submitEndHandler = this.handleSubmitEnd.bind(this) - this.element.addEventListener("input", this._inputHandler) + this.inputTarget.addEventListener("input", this._inputHandler) // keydown listens with capture so the picker handles Enter/Arrows // before anything else on the page sees them. - this.element.addEventListener("keydown", this._keydownHandler, true) + this.inputTarget.addEventListener("keydown", this._keydownHandler, true) document.addEventListener("click", this._docClickHandler) // Notify the global Web Push encouragement banner that this user just // posted a comment — gives the banner a chance to surface itself. - this._submitForm = this.element.closest("form") + this._submitForm = this.inputTarget.closest("form") if (this._submitForm) { this._submitForm.addEventListener("turbo:submit-end", this._submitEndHandler) } } disconnect() { - this.element.removeEventListener("input", this._inputHandler) - this.element.removeEventListener("keydown", this._keydownHandler, true) + this.inputTarget.removeEventListener("input", this._inputHandler) + this.inputTarget.removeEventListener("keydown", this._keydownHandler, true) document.removeEventListener("click", this._docClickHandler) if (this._submitForm) { this._submitForm.removeEventListener("turbo:submit-end", this._submitEndHandler) @@ -94,32 +96,82 @@ export default class extends Controller { // Submit-on-Enter (only when picker is closed). if (commandFor("reply", event) === "submit") { - const form = this.element.closest("form") + const form = this.inputTarget.closest("form") if (!form) return event.preventDefault() - if (this.element.closest('[data-dictation-busy="true"]')) return + if (this.inputTarget.closest('[data-dictation-busy="true"]')) return const submitter = form.querySelector('[type="submit"]') if (submitter?.disabled) return form.requestSubmit(submitter) } } - // Mod+K matches the document editor's link command, writing Markdown. + // Keep the selection while the in-app URL field has focus. link(event) { if (commandFor("reply", event) !== "link") return event.preventDefault() - const { selectionStart: start, selectionEnd: end } = this.element - const href = window.prompt("Link URL (https://…)")?.trim() - if (!href || !LINK_URL.test(href)) return - this.element.focus() - this.element.setSelectionRange(start, end) - const label = this.element.value.slice(start, end) + event.stopPropagation() + if (this._debounce) clearTimeout(this._debounce) + this._fetchAbort?.abort() + this.closePicker() + const { selectionStart: start, selectionEnd: end, value } = this.inputTarget + this._linkSelection = { start, end, value } + this.linkUrlTarget.value = "" + this.clearLinkError() + this.draftTarget.inert = true + this.draftTarget.style.visibility = "hidden" + this.linkPanelTarget.hidden = false + this.linkUrlTarget.focus({ preventScroll: true }) + } + + linkKeydown(event) { + // Do not let Enter submit the comment or Escape close its window. + event.stopPropagation() + if (event.isComposing) return + if (event.key === "Escape") { + event.preventDefault() + this.cancelLink() + } else if (event.key === "Enter" && event.target === this.linkUrlTarget) { + event.preventDefault() + this.applyLink() + } + } + + clearLinkError() { + this.linkErrorTarget.hidden = true + this.linkUrlTarget.removeAttribute("aria-invalid") + } + + applyLink() { + const href = this.linkUrlTarget.value.trim() + if (!LINK_URL.test(href) || /\s/.test(href)) { + this.linkErrorTarget.hidden = false + this.linkUrlTarget.setAttribute("aria-invalid", "true") + this.linkUrlTarget.focus({ preventScroll: true }) + return + } + const selection = this._linkSelection + this.cancelLink() + // Dictation or a remote draft update must not replace a stale selection. + if (!selection || this.inputTarget.value !== selection.value) return + const label = selection.value.slice(selection.start, selection.end) this.insertLink(label, href) - if (!label) this.element.setSelectionRange(start + 1, start + 1) + if (!label) this.inputTarget.setSelectionRange(selection.start + 1, selection.start + 1) + } + + cancelLink() { + this.linkPanelTarget.hidden = true + this.draftTarget.inert = false + this.draftTarget.style.visibility = "" + this.inputTarget.focus({ preventScroll: true }) + if (this._linkSelection) { + this.inputTarget.setSelectionRange(this._linkSelection.start, this._linkSelection.end) + } + this._linkSelection = null } pasteLink(event) { - const { selectionStart: start, selectionEnd: end, value } = this.element + const { selectionStart: start, selectionEnd: end, value } = this.inputTarget const href = event.clipboardData?.getData("text/plain").trim() const label = value.slice(start, end) if (!label || !LINK_URL.test(href) || /\s/.test(href) || LINK_URL.test(label.trim())) return @@ -138,8 +190,8 @@ export default class extends Controller { // insertText keeps the edit on the browser's undo stack and fires input. insertText(text) { if (document.execCommand("insertText", false, text)) return - this.element.setRangeText(text, this.element.selectionStart, this.element.selectionEnd, "end") - this.element.dispatchEvent(new Event("input", { bubbles: true })) + this.inputTarget.setRangeText(text, this.inputTarget.selectionStart, this.inputTarget.selectionEnd, "end") + this.inputTarget.dispatchEvent(new Event("input", { bubbles: true })) } handleInput(_event) { @@ -165,7 +217,7 @@ export default class extends Controller { } detectMentionTrigger() { - const ta = this.element + const ta = this.inputTarget const caret = ta.selectionStart if (caret !== ta.selectionEnd) return null const value = ta.value @@ -223,7 +275,7 @@ export default class extends Controller { this._picker.className = "mention-picker" this._picker.setAttribute("role", "listbox") // A nested top-layer picker can extend beyond the glass window. - const host = this.element.closest(".comment-window") || document.body + const host = this.inputTarget.closest(".comment-window") || document.body host.appendChild(this._picker) if (host !== document.body) { this._picker.setAttribute("popover", "manual") @@ -297,7 +349,7 @@ export default class extends Controller { positionPicker() { if (!this._picker) return - const rect = this.element.getBoundingClientRect() + const rect = this.inputTarget.getBoundingClientRect() const fixed = this._picker.hasAttribute("popover") const top = Math.max(12, Math.min(rect.bottom + 4, window.innerHeight - this._picker.offsetHeight - 12)) this._picker.style.top = `${fixed ? top : window.scrollY + top}px` @@ -310,7 +362,7 @@ export default class extends Controller { this.closePicker() return } - const ta = this.element + const ta = this.inputTarget const caret = ta.selectionStart const before = ta.value.slice(0, this._triggerStart) const after = ta.value.slice(caret) @@ -343,7 +395,7 @@ export default class extends Controller { handleDocumentClick(event) { if (!this._picker) return if (this._picker.contains(event.target)) return - if (event.target === this.element) return + if (event.target === this.inputTarget) return this.closePicker() } } diff --git a/engine/app/views/coplan/agent_instructions/guides/comments.text.erb b/engine/app/views/coplan/agent_instructions/guides/comments.text.erb index 64f5c3d7..3a24712f 100644 --- a/engine/app/views/coplan/agent_instructions/guides/comments.text.erb +++ b/engine/app/views/coplan/agent_instructions/guides/comments.text.erb @@ -98,3 +98,12 @@ Discussion is the default. An approval means “satisfied with this exact versio - `DELETE` the same path withdraws the request and records the withdrawing actor and time in review activity. Earlier request cycles and decisions remain. It clears the reviewer's request nudge, not the author's unread responses. Archiving a plan does not withdraw pending reviews. - Removing a reviewer in the web management pane is different from API withdrawal: it deletes their request and past review decisions. Adding them again creates a new pending request with a new ID. Requests to the removed ID return 404; stop using that ID and fetch the current requests. Never retry an old response against a replacement request. - Only the requested reviewer's credential may `POST /api/v1/plans/:plan_id/review_requests/:id/respond` with `{ "revision": 4, "generation": 2, "decision": "approved", "note": "optional public note", "idempotency_key": "RESPONSE_UUID" }` or `{ "revision": 4, "generation": 2, "decision": "changes_requested", "note": "what needs adjustment", "idempotency_key": "RESPONSE_UUID" }`. Use the generation returned by the request. Edits advance it only when automatic resets are enabled; re-requests always advance it. A stale or missing generation after the first cycle returns 409 with `code: review_request_changed`. Change requests create ordinary public comment threads. Agent responses are explicitly attributed to the token's agent acting for its human owner. A stale revision returns 409 with `code: review_version_changed`; re-read before responding. A new response to a withdrawn request returns 409 with `code: review_request_withdrawn`; stop responding unless the author requests another look. Replace `RESPONSE_UUID` with a fresh UUID for each intended response. Retry with the same UUID and identical fields if the HTTP response is lost. Use a new UUID for another review, even for the same version and decision. A keyed retry returns the original result even after a plan edit or withdrawal. It does not complete a newly re-requested task. Without a key, identical fields are treated as a retry. Retries may use a renewed token for the same human principal. The original response keeps its original agent and token attribution. Reusing a key with different fields returns 422. Never manufacture a human sign-off. + +## Links in the web composer + +People can select draft text and press Cmd+K on Mac or Ctrl+K elsewhere to +open the composer's Link URL field. Enter or Insert link adds the Markdown +link without posting the comment. Escape or Cancel link preserves the draft. +Pasting a plain HTTP, HTTPS, or mailto URL over selected text also creates a +link. Both paths escape label and destination delimiters. These controls work +for new comments, replies, and comment edits. diff --git a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb index 1ab6f360..776e4ce4 100644 --- a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb +++ b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb @@ -1,6 +1,8 @@ <% label = local_assigns.fetch(:label, "New comment") %> <% submit_label = local_assigns.fetch(:submit_label, "Comment") %> -
+
+
coplan--comment-form#link paste->coplan--comment-form#pasteLink", - "coplan--comment-form-search-url-value": search_users_path }.merge(local_assigns.fetch(:textarea_data, {})) %> + data: { "coplan--comment-form-target": "input", "coplan--dictation-target": "input", + action: "keydown->coplan--comment-form#link paste->coplan--comment-form#pasteLink" }.merge(local_assigns.fetch(:textarea_data, {})) %>
@@ -31,4 +32,17 @@
+
+
diff --git a/engine/db/demo/content/comment-links.md b/engine/db/demo/content/comment-links.md new file mode 100644 index 00000000..45c5e02a --- /dev/null +++ b/engine/db/demo/content/comment-links.md @@ -0,0 +1,11 @@ +# Link selected text in comments + +Choose Add comment, type `hi`, and select the word. Press Cmd+K on Mac or +Ctrl+K elsewhere. The composer shows a Link URL field without leaving the +page. Enter `https://example.com/search?q=)` and choose Insert link. The draft +now contains a Markdown link; submit the comment to see the clickable label. +Escape or Cancel link returns to the unchanged draft. Undo reverses insertion. + +Also try selecting `Use [draft` or `Path \[draft] (notes)`. Brackets and +backslashes stay in the posted label. Pasting a plain URL over a selection +links it immediately. The same controls work in replies and Edit comment. diff --git a/engine/lib/coplan/development_seed.rb b/engine/lib/coplan/development_seed.rb index 364f25b8..be89a096 100644 --- a/engine/lib/coplan/development_seed.rb +++ b/engine/lib/coplan/development_seed.rb @@ -110,6 +110,9 @@ module DevelopmentSeed { key: "formatting-gallery", author: "hampton", type: "General", title: "Rich text: headings, tasks, code, quotes, and footnotes", tags: %w[demo markdown], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "Markdown formatting, editable template notes hidden while reading, reversible bullet typing, list exit, task toggles, code languages, image, mention, and footnotes" }, + { key: "comment-links", author: "hampton", type: "General", title: "Comments: link selected text", + tags: %w[demo comments], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", + showcase: "In-app URL field with Cmd/Ctrl+K, URL paste over selected text, cancellation, undo, and escaped link labels" }, { key: "reading-specimen", author: "hampton", type: "Design Doc", title: "Reading specimen: webhook delivery through a durable outbox", tags: %w[demo markdown reading], visibility: "published", folder: "Demo content/Rich text", group: "Tables and rich text", showcase: "A realistic agent-drafted design doc for judging reading: common Markdown elements, SQL, Ruby, YAML, and diff code, a long scrolling line, two Mermaid diagrams, and open, answered, resolved, diagram, table-cell, and general discussions" }, diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb index 814df826..0c0bbfac 100644 --- a/spec/lib/development_seed_spec.rb +++ b/spec/lib/development_seed_spec.rb @@ -261,6 +261,9 @@ def seeded_plan(key) expect(formatting_content).to include("- Item with a table.", " | Alpha | Ready |", "blank item after this table") expect(formatting_content).to include("## Try numbered and lettered lists", "1) First numbered step.", "A) First uppercase lettered step.", "a) First lowercase lettered step.") + links_content = seeded_plan("comment-links").current_content + expect(links_content).to include("# Link selected text in comments", "Cmd+K", "Cancel link", "https://example.com/search?q=)") + thread_count = CoPlan::CommentThread.count reference_count = gallery.references.count described_class.call @@ -268,6 +271,7 @@ def seeded_plan(key) expect(gallery.references.count).to eq(reference_count) expect(formatting.reload.current_revision).to eq(1) expect(formatting.current_content).to eq(formatting_content) + expect(seeded_plan("comment-links").current_content).to eq(links_content) end it "seeds discussions with reactions and preserves reaction changes on re-seed" do diff --git a/spec/system/comment_composer_links_spec.rb b/spec/system/comment_composer_links_spec.rb index 544515e6..1b612b05 100644 --- a/spec/system/comment_composer_links_spec.rb +++ b/spec/system/comment_composer_links_spec.rb @@ -5,7 +5,9 @@ let(:plan) { create(:plan, :considering, created_by_user: author) } let!(:thread_record) { create(:comment_thread, plan: plan, plan_version: plan.current_plan_version, created_by_user: author) } let!(:comment) { create(:comment, comment_thread: thread_record, author_id: author.id, body_markdown: "Start here") } - let(:textarea) { find(".comment-window:popover-open textarea") } + def textarea + find(".comment-window:popover-open textarea") + end let(:mod) { RUBY_PLATFORM.include?("darwin") ? :meta : :control } before do @@ -34,13 +36,20 @@ def paste(text) JS end + def insert_link(href) + textarea.send_keys([ mod, "k" ]) + expect(page).to have_field("Link URL", focused: true) + fill_in "Link URL", with: href + find_field("Link URL").send_keys(:enter) + end + def caret page.evaluate_script("[arguments[0].selectionStart, arguments[0].selectionEnd]", textarea) end it "links the selection with Mod+K, undoes it, and posts it as a link" do select_text(4, 19) - accept_prompt(with: "https://example.com") { textarea.send_keys([ mod, "k" ]) } + insert_link("https://example.com") expect(textarea.value).to eq("See [Design Overview](https://example.com)") textarea.send_keys([ mod, "z" ]) expect(textarea.value).to eq("See Design Overview") @@ -60,7 +69,7 @@ def caret textarea.fill_in with: label select_text(0, label.length) if method == :keyboard - accept_prompt(with: href) { textarea.send_keys([ mod, "k" ]) } + insert_link(href) else expect(paste(href)).to be(false) end @@ -79,16 +88,52 @@ def caret it "inserts an empty link at the caret without a selection" do select_text(3, 3) - accept_prompt(with: "mailto:team@example.com") { textarea.send_keys([ mod, "k" ]) } + insert_link("mailto:team@example.com") expect(textarea.value).to eq("See[](mailto:team@example.com) Design Overview") expect(caret).to eq([ 4, 4 ]) end - it "leaves the draft alone when the prompt is cancelled or the URL is invalid" do + it "leaves the draft alone when link insertion is cancelled or the URL is invalid" do + select_text(4, 19) + textarea.send_keys([ mod, "k" ]) + find_field("Link URL").send_keys(:escape) + expect(caret).to eq([ 4, 19 ]) + insert_link("javascript:alert(1)") + expect(page).to have_text("Enter a URL starting with") + click_button "Cancel link" + expect(textarea.value).to eq("See Design Overview") + end + + it "cancels from the keyboard without submitting or moving the composer" do select_text(4, 19) - dismiss_prompt { textarea.send_keys([ mod, "k" ]) } - accept_prompt(with: "javascript:alert(1)") { textarea.send_keys([ mod, "k" ]) } + before = page.evaluate_script("document.querySelector('.comment-window:popover-open').getBoundingClientRect().toJSON()") + textarea.send_keys([ mod, "k" ]) + fill_in "Link URL", with: "https://example.com" + find_button("Cancel link").send_keys(:enter) expect(textarea.value).to eq("See Design Overview") + expect(caret).to eq([ 4, 19 ]) + expect(page).to have_field("Your reply", enable_aria_label: true, focused: true) + after = page.evaluate_script("document.querySelector('.comment-window:popover-open').getBoundingClientRect().toJSON()") + expect(after).to eq(before) + expect(thread_record.comments.count).to eq(1) + end + + it "inserts a link without posting until the author submits the reply" do + select_text(4, 19) + insert_link("https://example.com") + expect(thread_record.comments.count).to eq(1) + expect(page).to have_field("Your reply", enable_aria_label: true, focused: true) + expect(textarea.value).to eq("See [Design Overview](https://example.com)") + end + + it "links text while editing an existing comment" do + within(".comment-window:popover-open") { click_button "Edit" } + textarea.fill_in with: "hi" + select_text(0, 2) + insert_link("https://example.com/edit") + click_button "Save changes" + expect(page).to have_link("hi", href: "https://example.com/edit") + expect(comment.reload.body_markdown).to eq("[hi](https://example.com/edit)") end it "links a selection when a URL is pasted over it and leaves other pastes alone" do From f2b0f8688a6d70440c578e46044830c0d15e43aa Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Thu, 5 Nov 2026 15:57:23 -0600 Subject: [PATCH 5/9] Preserve drafts when cancelling comment links Signed-off-by: Hampton Lintorn-Catlin --- .../app/assets/stylesheets/coplan/review.css | 2 +- .../coplan/comment_form_controller.js | 27 ++++++++++++------- .../coplan/source_comments_controller.js | 2 ++ .../comment_threads/_composer_fields.html.erb | 7 ++--- spec/system/comment_composer_links_spec.rb | 19 ++++++++++++- spec/system/source_comments_spec.rb | 20 ++++++++++++++ 6 files changed, 63 insertions(+), 14 deletions(-) diff --git a/engine/app/assets/stylesheets/coplan/review.css b/engine/app/assets/stylesheets/coplan/review.css index 27ad57d2..4a8fb4fc 100644 --- a/engine/app/assets/stylesheets/coplan/review.css +++ b/engine/app/assets/stylesheets/coplan/review.css @@ -72,13 +72,13 @@ border-left: 2px solid var(--color-primary); background: var(--color-interaction-hover-bg); margin: 0 0 8px; } .composer { min-width: 0; position: relative; } +.composer__draft[inert] { visibility: hidden; opacity: 0; } .composer__link { position: absolute; inset: 0; overflow: auto; } .composer__link[hidden] { display: none; } .composer__link label { display: block; font-size: 12px; color: var(--color-text-muted); } .composer__link input { display: block; box-sizing: border-box; width: 100%; margin-top: 4px; padding: 8px 10px; border: 1px solid var(--color-border); border-radius: 9px; background: var(--color-surface); color: var(--color-text); font: 13px var(--font-sans); } .composer__link input:focus { outline: 2px solid var(--color-primary); outline-offset: -1px; } .composer__link .composer__send-actions { justify-content: flex-end; margin-top: 8px; } -.composer__link p { margin: 6px 0; color: var(--color-text-muted); } .composer__writing { display: flex; flex-direction: column; min-height: 0; } .composer .composer__input { width: 100%; min-height: 70px; padding: 9px 10px; margin: 0; diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index 59f99f49..b9ce8453 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -16,7 +16,7 @@ export default class extends Controller { // Search URL is supplied per-form via a Stimulus value so it respects the // engine's mount point (host apps may mount Coplan under e.g. `/coplan`). // The default is fine for hosts that mount at root. - static targets = ["input", "draft", "linkPanel", "linkUrl", "linkError"] + static targets = ["input", "draft", "linkPanel", "linkUrl", "linkLabel", "linkError"] static values = { searchUrl: { type: String, default: "/users/search" } @@ -119,7 +119,6 @@ export default class extends Controller { this.linkUrlTarget.value = "" this.clearLinkError() this.draftTarget.inert = true - this.draftTarget.style.visibility = "hidden" this.linkPanelTarget.hidden = false this.linkUrlTarget.focus({ preventScroll: true }) } @@ -139,21 +138,32 @@ export default class extends Controller { clearLinkError() { this.linkErrorTarget.hidden = true + this.linkLabelTarget.hidden = false this.linkUrlTarget.removeAttribute("aria-invalid") } + showLinkError(message) { + this.linkErrorTarget.textContent = message + this.linkErrorTarget.hidden = false + this.linkLabelTarget.hidden = true + this.linkUrlTarget.setAttribute("aria-invalid", "true") + this.linkUrlTarget.focus({ preventScroll: true }) + } + applyLink() { const href = this.linkUrlTarget.value.trim() if (!LINK_URL.test(href) || /\s/.test(href)) { - this.linkErrorTarget.hidden = false - this.linkUrlTarget.setAttribute("aria-invalid", "true") - this.linkUrlTarget.focus({ preventScroll: true }) + this.showLinkError("Use http(s):// or mailto: without spaces.") return } const selection = this._linkSelection + // Dictation can update the draft while this field has focus. Keep the + // URL available and explain why the original selection is no longer safe. + if (!selection || this.inputTarget.value !== selection.value) { + this.showLinkError("Draft changed. Cancel and reselect text.") + return + } this.cancelLink() - // Dictation or a remote draft update must not replace a stale selection. - if (!selection || this.inputTarget.value !== selection.value) return const label = selection.value.slice(selection.start, selection.end) this.insertLink(label, href) if (!label) this.inputTarget.setSelectionRange(selection.start + 1, selection.start + 1) @@ -162,9 +172,8 @@ export default class extends Controller { cancelLink() { this.linkPanelTarget.hidden = true this.draftTarget.inert = false - this.draftTarget.style.visibility = "" this.inputTarget.focus({ preventScroll: true }) - if (this._linkSelection) { + if (this._linkSelection && this.inputTarget.value === this._linkSelection.value) { this.inputTarget.setSelectionRange(this._linkSelection.start, this._linkSelection.end) } this._linkSelection = null diff --git a/engine/app/javascript/controllers/coplan/source_comments_controller.js b/engine/app/javascript/controllers/coplan/source_comments_controller.js index 4fd7f35d..036e73d6 100644 --- a/engine/app/javascript/controllers/coplan/source_comments_controller.js +++ b/engine/app/javascript/controllers/coplan/source_comments_controller.js @@ -185,6 +185,8 @@ export default class extends Controller { } dismiss(event, restoreFocus = true) { + // Let nested link entry cancel itself before dismissing the discussion. + if (event?.key === "Escape" && event.target.closest?.(".composer__link:not([hidden])")) return // This document capture handler runs before the nested menu receives Escape. if (event?.key === "Escape" && document.querySelector(".microphone-settings__panel:popover-open, .comment-reactions__picker:popover-open")) return this.panelTarget.dispatchEvent(new CustomEvent("coplan:composer-close", { bubbles: true })) diff --git a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb index 776e4ce4..1823caf4 100644 --- a/engine/app/views/coplan/comment_threads/_composer_fields.html.erb +++ b/engine/app/views/coplan/comment_threads/_composer_fields.html.erb @@ -35,11 +35,12 @@