From dbfa86378a245801d7394ded25b633dffc01dd5e Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Mon, 4 May 2026 13:23:38 -0400 Subject: [PATCH 1/3] Add @-mentions in comments Type @username in any comment textarea to open a typeahead picker. On submit, plain @username is rewritten to canonical [@user](mention:user) markdown for round-trip-safe storage. Renders as a styled chip; mentioned users get an inbox notification. Generated with Amp Amp-Thread-ID: https://ampcode.com/threads/T-019de464-3784-7138-9110-2c69513f8c7c Co-authored-by: Amp --- .../assets/stylesheets/coplan/application.css | 66 +++++ .../controllers/coplan/users_controller.rb | 40 +++ engine/app/helpers/coplan/markdown_helper.rb | 19 +- .../coplan/comment_form_controller.js | 270 +++++++++++++++++- engine/app/models/coplan/comment.rb | 10 + engine/app/models/coplan/notification.rb | 2 +- .../coplan/comments/process_mentions.rb | 62 ++++ .../coplan/comments/rewrite_mentions.rb | 46 +++ engine/config/routes.rb | 4 + spec/helpers/markdown_helper_spec.rb | 24 ++ spec/requests/users_spec.rb | 42 +++ .../coplan/comments/process_mentions_spec.rb | 54 ++++ .../coplan/comments/rewrite_mentions_spec.rb | 79 +++++ 13 files changed, 708 insertions(+), 10 deletions(-) create mode 100644 engine/app/controllers/coplan/users_controller.rb create mode 100644 engine/app/services/coplan/comments/process_mentions.rb create mode 100644 engine/app/services/coplan/comments/rewrite_mentions.rb create mode 100644 spec/requests/users_spec.rb create mode 100644 spec/services/coplan/comments/process_mentions_spec.rb create mode 100644 spec/services/coplan/comments/rewrite_mentions_spec.rb diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 25d3fa6..8eeb5fe 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2278,3 +2278,69 @@ body:has(.comment-toolbar) .main-content { display: none !important; } } + +/* @-mention chip — matches Google Docs style. Uses theme tokens so it + adapts to dark mode automatically via CoPlan's existing variables. */ +.markdown-rendered .mention { + display: inline; + background: var(--color-primary-light); + color: var(--color-primary); + border-radius: 12px; + padding: 1px 8px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +/* Mention picker dropdown for the comment textarea */ +.mention-picker { + position: absolute; + z-index: 100; + background: var(--color-surface); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 240px; + max-width: 360px; + max-height: 240px; + overflow-y: auto; + list-style: none; + margin: 0; + padding: 4px 0; +} +.mention-picker[hidden] { display: none; } +.mention-picker__item { + padding: 6px 12px; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; +} +.mention-picker__item--highlighted { + background: var(--color-primary-light); + color: var(--color-primary); +} +.mention-picker__item--highlighted .mention-picker__name { + color: var(--color-primary); +} +.mention-picker__item--disabled { + cursor: not-allowed; + opacity: 0.55; +} +.mention-picker__item--disabled.mention-picker__item--highlighted { + background: transparent; +} +.mention-picker__name { + font-weight: 500; + color: var(--color-text); +} +.mention-picker__meta { + font-size: 0.85em; + color: var(--color-text-muted); +} +.mention-picker__empty { + padding: 6px 12px; + color: var(--color-text-muted); + font-size: 0.9em; +} diff --git a/engine/app/controllers/coplan/users_controller.rb b/engine/app/controllers/coplan/users_controller.rb new file mode 100644 index 0000000..26e2007 --- /dev/null +++ b/engine/app/controllers/coplan/users_controller.rb @@ -0,0 +1,40 @@ +module CoPlan + # Session-authenticated user typeahead for in-app pickers (reviewer + # assignment, @-mentions, etc.). + # + # The API equivalent at CoPlan::Api::V1::UsersController#search exists for + # external callers and uses bearer-token auth — it can't be hit from the + # browser since fetch() with `credentials: same-origin` only sends cookies. + # This controller provides the same JSON shape behind the regular session. + class UsersController < ApplicationController + ALLOWED_FIELDS = %i[id name email username avatar_url title team].freeze + + def search + query = params[:q].to_s.strip + if query.blank? + return render json: [] + end + + users = if CoPlan.configuration.user_search + CoPlan.configuration.user_search.call(query) + else + sanitized = CoPlan::User.sanitize_sql_like(query) + CoPlan::User + .where("name LIKE :q OR email LIKE :q OR username LIKE :q", q: "%#{sanitized}%") + .limit(20) + end + + render json: users.map { |u| user_json(u) } + end + + private + + def user_json(user) + if user.respond_to?(:id) + ALLOWED_FIELDS.to_h { |f| [f, user.public_send(f)] } + else + ALLOWED_FIELDS.to_h { |f| [f, user[f]] } + end + end + end +end diff --git a/engine/app/helpers/coplan/markdown_helper.rb b/engine/app/helpers/coplan/markdown_helper.rb index b610f22..51406fb 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -14,15 +14,30 @@ module MarkdownHelper details summary ].freeze - ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target].freeze + ALLOWED_ATTRIBUTES = %w[id class href src alt title type checked disabled data-line-text data-action data-coplan--checkbox-target data-mention-username].freeze + + # Matches `[@username](mention:username)` where the bracket text and link + # target encode the same username. Username allows letters, digits, dots, + # dashes, and underscores. The pattern must round-trip exactly so that + # casual `[foo](mention:bar)` typed by hand doesn't get rendered as a chip. + MENTION_PATTERN = /\[@([\w.-]+)\]\(mention:\1\)/ def render_markdown(content, interactive: true) - html = Commonmarker.to_html(content.to_s.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil }) + transformed = transform_mentions(content.to_s) + html = Commonmarker.to_html(transformed.encode("UTF-8"), options: { render: { unsafe: true } }, plugins: { syntax_highlighter: nil }) sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized tag.div(result.html_safe, class: "markdown-rendered") end + def transform_mentions(content) + content.gsub(MENTION_PATTERN) do + username = ::Regexp.last_match(1) + escaped = ERB::Util.html_escape(username) + %(@#{escaped}) + end + end + def markdown_to_plain_text(content) html = Commonmarker.to_html(content.to_s.encode("UTF-8"), plugins: { syntax_highlighter: nil }) Nokogiri::HTML::DocumentFragment.parse(html).text.squish diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index 1477ded..530e8be 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -1,14 +1,270 @@ import { Controller } from "@hotwired/stimulus" +// 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. +// +// 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 +// submitting the form). export default class extends Controller { - submitOnEnter(event) { - if (event.key !== "Enter") return - if (event.shiftKey || event.isComposing) return + connect() { + this._picker = null + this._results = [] + this._highlightIndex = -1 + this._triggerStart = -1 // index of the `@` in the textarea value + this._debounce = null + this._docClickHandler = this.handleDocumentClick.bind(this) + this._inputHandler = this.handleInput.bind(this) + this._keydownHandler = this.handleKeydown.bind(this) - const form = event.target.closest("form") - if (!form) return + this.element.addEventListener("input", this._inputHandler) + // keydown listens with capture so the picker handles Enter/Arrows + // before the legacy submitOnEnter action fires. + this.element.addEventListener("keydown", this._keydownHandler, true) + document.addEventListener("click", this._docClickHandler) + } + + disconnect() { + this.element.removeEventListener("input", this._inputHandler) + this.element.removeEventListener("keydown", this._keydownHandler, true) + document.removeEventListener("click", this._docClickHandler) + this.closePicker() + if (this._debounce) clearTimeout(this._debounce) + } + + // Legacy keep-alive entry point (still wired in views via data-action). + // Real Enter handling now happens in handleKeydown. + submitOnEnter(_event) {} + + handleKeydown(event) { + if (this.pickerOpen()) { + if (event.key === "ArrowDown") { + event.preventDefault() + event.stopImmediatePropagation() + this.moveHighlight(1) + return + } + if (event.key === "ArrowUp") { + event.preventDefault() + event.stopImmediatePropagation() + this.moveHighlight(-1) + return + } + if (event.key === "Enter" || event.key === "Tab") { + if (this._highlightIndex >= 0 && this._results[this._highlightIndex]) { + event.preventDefault() + event.stopImmediatePropagation() + this.selectResult(this._results[this._highlightIndex]) + return + } + } + if (event.key === "Escape") { + event.preventDefault() + event.stopImmediatePropagation() + this.closePicker() + return + } + } + + // Submit-on-Enter (only when picker is closed). + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + const form = this.element.closest("form") + if (!form) return + event.preventDefault() + form.requestSubmit() + } + } + + handleInput(_event) { + const trigger = this.detectMentionTrigger() + if (!trigger) { + this.closePicker() + return + } + + this._triggerStart = trigger.start + const query = trigger.query + + if (this._debounce) clearTimeout(this._debounce) + + if (query.length === 0) { + // Just typed `@` — show no results yet, but reserve the slot so + // typing one more char shows the picker immediately. + this.closePicker() + return + } + + this._debounce = setTimeout(() => this.fetchResults(query), 150) + } + + detectMentionTrigger() { + const ta = this.element + const caret = ta.selectionStart + if (caret !== ta.selectionEnd) return null + const value = ta.value + // Walk backwards from caret to find `@` at a word boundary. + let i = caret - 1 + while (i >= 0) { + const ch = value[i] + if (ch === "@") { + const before = i === 0 ? " " : value[i - 1] + // Must be at start-of-text or preceded by whitespace/punctuation. + if (/[\s(\[]/.test(before) || i === 0) { + const query = value.slice(i + 1, caret) + // Bail if the in-progress query contains whitespace or markdown chars. + if (/[\s\]\)]/.test(query)) return null + return { start: i, query } + } + return null + } + // Stop if we hit whitespace before finding `@`. + if (/\s/.test(ch)) return null + i-- + } + return null + } + + async fetchResults(query) { + try { + const response = await fetch(`/users/search?q=${encodeURIComponent(query)}`, { + headers: { Accept: "application/json" }, + credentials: "same-origin", + }) + if (!response.ok) { + this.closePicker() + return + } + const users = await response.json() + this._results = users || [] + this._highlightIndex = this._results.length > 0 ? 0 : -1 + this.renderPicker() + } catch (err) { + console.error("[mention-picker] fetch failed", err) + this.closePicker() + } + } + + renderPicker() { + if (!this._picker) { + this._picker = document.createElement("ul") + this._picker.className = "mention-picker" + this._picker.setAttribute("role", "listbox") + // Append to body so it can overflow form/card boundaries. + document.body.appendChild(this._picker) + } + + this._picker.innerHTML = "" + + if (this._results.length === 0) { + const empty = document.createElement("li") + empty.className = "mention-picker__empty" + empty.textContent = "No matches" + this._picker.appendChild(empty) + } else { + this._results.forEach((user, idx) => { + const li = document.createElement("li") + li.className = "mention-picker__item" + if (!user.username) li.classList.add("mention-picker__item--disabled") + if (idx === this._highlightIndex) li.classList.add("mention-picker__item--highlighted") + li.dataset.index = idx + + const name = document.createElement("span") + name.className = "mention-picker__name" + name.textContent = user.username ? `${user.name} · @${user.username}` : (user.name || "(unnamed)") + li.appendChild(name) + + const metaParts = [user.title, user.team, user.email].filter(Boolean) + if (!user.username) metaParts.unshift("no username — can't mention") + if (metaParts.length) { + const meta = document.createElement("span") + meta.className = "mention-picker__meta" + meta.textContent = metaParts.join(" · ") + li.appendChild(meta) + } + + li.addEventListener("mousedown", (e) => { + e.preventDefault() // keep textarea focus + if (!user.username) return + this.selectResult(user) + }) + li.addEventListener("mouseenter", () => { + this._highlightIndex = idx + this.applyHighlight() + }) + + this._picker.appendChild(li) + }) + } + + this.positionPicker() + this._picker.hidden = false + } + + applyHighlight() { + if (!this._picker) return + const items = this._picker.querySelectorAll(".mention-picker__item") + items.forEach((el, i) => { + el.classList.toggle("mention-picker__item--highlighted", i === this._highlightIndex) + }) + } + + moveHighlight(delta) { + if (this._results.length === 0) return + this._highlightIndex = (this._highlightIndex + delta + this._results.length) % this._results.length + this.applyHighlight() + const items = this._picker?.querySelectorAll(".mention-picker__item") || [] + items[this._highlightIndex]?.scrollIntoView({ block: "nearest" }) + } + + positionPicker() { + if (!this._picker) return + const rect = this.element.getBoundingClientRect() + this._picker.style.top = `${window.scrollY + rect.bottom + 4}px` + this._picker.style.left = `${window.scrollX + rect.left}px` + this._picker.style.minWidth = `${Math.min(rect.width, 360)}px` + } + + selectResult(user) { + if (!user || !user.username) { + this.closePicker() + return + } + const ta = this.element + const caret = ta.selectionStart + const before = ta.value.slice(0, this._triggerStart) + const after = ta.value.slice(caret) + // Insert plain `@username ` — the server rewrites it to the canonical + // `[@username](mention:username)` form on save, so the textarea stays + // clean while editing. + const insertion = `@${user.username} ` + ta.value = `${before}${insertion}${after}` + const newCaret = (before + insertion).length + ta.setSelectionRange(newCaret, newCaret) + ta.dispatchEvent(new Event("input", { bubbles: true })) + ta.focus() + this.closePicker() + } + + closePicker() { + if (this._picker) { + this._picker.remove() + this._picker = null + } + this._results = [] + this._highlightIndex = -1 + this._triggerStart = -1 + } + + pickerOpen() { + return this._picker !== null && !this._picker.hidden + } - event.preventDefault() - form.requestSubmit() + handleDocumentClick(event) { + if (!this._picker) return + if (this._picker.contains(event.target)) return + if (event.target === this.element) return + this.closePicker() } } diff --git a/engine/app/models/coplan/comment.rb b/engine/app/models/coplan/comment.rb index 633ba34..2928f62 100644 --- a/engine/app/models/coplan/comment.rb +++ b/engine/app/models/coplan/comment.rb @@ -9,7 +9,9 @@ class Comment < ApplicationRecord validates :agent_name, presence: { message: "is required for agent comments" }, if: -> { author_type == "local_agent" } validates :agent_name, length: { maximum: 20 }, allow_nil: true + before_save :rewrite_plain_mentions, if: :body_markdown_changed? after_create_commit :notify_plan_author, if: :first_comment_in_thread? + after_create_commit :process_mentions def agent? agent_name.present? || author_type.in?(%w[local_agent cloud_persona]) @@ -35,5 +37,13 @@ def first_comment_in_thread? def notify_plan_author CoPlan::NotificationJob.perform_later("comment_created", { comment_thread_id: comment_thread_id }) end + + def process_mentions + CoPlan::Comments::ProcessMentions.call(self) + end + + def rewrite_plain_mentions + self.body_markdown = CoPlan::Comments::RewriteMentions.call(body_markdown) + end end end diff --git a/engine/app/models/coplan/notification.rb b/engine/app/models/coplan/notification.rb index e693e2a..cd50c46 100644 --- a/engine/app/models/coplan/notification.rb +++ b/engine/app/models/coplan/notification.rb @@ -1,6 +1,6 @@ module CoPlan class Notification < ApplicationRecord - REASONS = %w[new_comment reply agent_response status_change].freeze + REASONS = %w[new_comment reply agent_response status_change mention].freeze belongs_to :user, class_name: "CoPlan::User" belongs_to :plan, class_name: "CoPlan::Plan" diff --git a/engine/app/services/coplan/comments/process_mentions.rb b/engine/app/services/coplan/comments/process_mentions.rb new file mode 100644 index 0000000..54f849e --- /dev/null +++ b/engine/app/services/coplan/comments/process_mentions.rb @@ -0,0 +1,62 @@ +module CoPlan + module Comments + # Parses a comment's body for `[@username](mention:username)` patterns, + # resolves them against CoPlan::User, and creates a Notification per + # mentioned user (skipping the author and de-duplicating). + # + # Self-mentions and unresolvable usernames are silently dropped — by + # design, since the chip still renders visually but no inbox row should + # appear for typos or self-loops. + class ProcessMentions + MENTION_PATTERN = CoPlan::MarkdownHelper::MENTION_PATTERN + + def self.call(comment) + new(comment).call + end + + def initialize(comment) + @comment = comment + end + + def call + usernames = extract_usernames + return if usernames.empty? + + mentioned_users = CoPlan::User.where(username: usernames) + + mentioned_users.each do |user| + next if user.id == @comment.author_id && @comment.author_type == "human" + + Notification.create!( + user_id: user.id, + plan_id: @comment.comment_thread.plan_id, + comment_thread_id: @comment.comment_thread_id, + comment_id: @comment.id, + reason: "mention" + ) + end + + broadcast_badge_updates(mentioned_users.pluck(:id) - [@comment.author_id]) + end + + private + + def extract_usernames + @comment.body_markdown.to_s.scan(MENTION_PATTERN).flatten.uniq + end + + def broadcast_badge_updates(user_ids) + return if user_ids.empty? + + counts = Notification.where(user_id: user_ids).unread.group(:user_id).count + user_ids.each do |user_id| + Broadcaster.update_to( + "coplan_notifications:#{user_id}", + target: "inbox-badge", + html: (counts[user_id] || 0).to_s + ) + end + end + end + end +end diff --git a/engine/app/services/coplan/comments/rewrite_mentions.rb b/engine/app/services/coplan/comments/rewrite_mentions.rb new file mode 100644 index 0000000..9e7aa02 --- /dev/null +++ b/engine/app/services/coplan/comments/rewrite_mentions.rb @@ -0,0 +1,46 @@ +module CoPlan + module Comments + # Rewrites plain `@username` mentions in a comment body into the canonical + # `[@username](mention:username)` markdown form, but only for usernames that + # resolve to a real CoPlan::User. Unresolved `@foo` is left as plain text. + # + # This runs `before_save` on Comment so: + # - The textarea stays visually clean while the user is typing. + # - The persisted body is round-trip-safe markdown — no DB lookup is + # needed at render time, and ProcessMentions can find mentions reliably. + # + # The lookbehind `(?@hampton') + end + + it "ignores mismatched [text](mention:other) links" do + html = helper.render_markdown("[hello](mention:hampton) is plain text") + expect(html).not_to include('class="mention"') + end + + it "leaves casual @-text alone" do + html = helper.render_markdown("just casually mentioning @hampton here") + expect(html).not_to include('class="mention"') + expect(html).to include("@hampton") + end + + it "escapes the username to prevent injection" do + html = helper.render_markdown("[@evil