` tags inside code, so they're
+ # naturally skipped here.
+ def transform_mention_anchors(html)
+ doc = Nokogiri::HTML::DocumentFragment.parse(html)
+ doc.css('a[href^="mention:"]').each do |anchor|
+ username = anchor["href"].sub(/\Amention:/, "")
+ next unless username.match?(/\A[\w.-]+\z/)
+ next unless anchor.content == "@#{username}"
+
+ span = Nokogiri::XML::Node.new("span", doc)
+ span["class"] = "mention"
+ span["data-mention-username"] = username
+ span.content = "@#{username}"
+ anchor.replace(span)
+ end
+ doc.to_html
+ 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..b770e3b 100644
--- a/engine/app/javascript/controllers/coplan/comment_form_controller.js
+++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js
@@ -1,14 +1,277 @@
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
+ // 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 values = {
+ searchUrl: { type: String, default: "/users/search" }
+ }
+
+ 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)
+
+ 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") && !event.isComposing) {
+ 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(`${this.searchUrlValue}?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()
+ }
+ }
- const form = event.target.closest("form")
- if (!form) return
+ 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..089af89 100644
--- a/engine/app/models/coplan/comment.rb
+++ b/engine/app/models/coplan/comment.rb
@@ -9,7 +9,11 @@ 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?
+ # Runs on save (not just create) so adding a mention via edit also
+ # notifies. ProcessMentions uses find_or_create_by to dedupe.
+ after_save_commit :process_mentions, if: :saved_change_to_body_markdown?
def agent?
agent_name.present? || author_type.in?(%w[local_agent cloud_persona])
@@ -35,5 +39,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..e71356b
--- /dev/null
+++ b/engine/app/services/coplan/comments/process_mentions.rb
@@ -0,0 +1,69 @@
+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)
+ author_user_id = @comment.author_type == "human" ? @comment.author_id : nil
+ notified_user_ids = []
+
+ mentioned_users.each do |user|
+ next if user.id == author_user_id
+
+ # find_or_create_by to dedupe across edits — if a comment is
+ # updated and re-mentions the same user, we don't pile on extra
+ # inbox rows.
+ notification = Notification.find_or_create_by!(
+ user_id: user.id,
+ comment_id: @comment.id,
+ reason: "mention"
+ ) do |n|
+ n.plan_id = @comment.comment_thread.plan_id
+ n.comment_thread_id = @comment.comment_thread_id
+ end
+ notified_user_ids << user.id if notification.previously_new_record?
+ end
+
+ broadcast_badge_updates(notified_user_ids)
+ 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 `(?
+ data-controller="coplan--comment-form"
+ data-coplan--comment-form-search-url-value="<%= search_users_path %>"
+ data-action="keydown->coplan--comment-form#submitOnEnter">