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/api/v1/users_controller.rb b/engine/app/controllers/coplan/api/v1/users_controller.rb deleted file mode 100644 index 27774ef..0000000 --- a/engine/app/controllers/coplan/api/v1/users_controller.rb +++ /dev/null @@ -1,36 +0,0 @@ -module CoPlan - module Api - module V1 - class UsersController < BaseController - 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", q: "%#{sanitized}%") - .limit(20) - end - render json: users.map { |u| user_json(u) } - end - - private - - ALLOWED_FIELDS = %i[id name email avatar_url title team].freeze - - 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 - end -end diff --git a/engine/app/controllers/coplan/users_controller.rb b/engine/app/controllers/coplan/users_controller.rb new file mode 100644 index 0000000..5003667 --- /dev/null +++ b/engine/app/controllers/coplan/users_controller.rb @@ -0,0 +1,42 @@ +module CoPlan + # Session-authenticated user typeahead for in-app pickers (the @-mention + # picker in the comment textarea today; future pickers welcome). + 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 + # Hook may return external candidates (LDAP, etc.) — keep only those + # whose username also exists in coplan_users, since RewriteMentions + # and ProcessMentions can only resolve local usernames. Surfacing + # external-only users would let the picker offer mentions that + # silently fall through to plain text on save. + candidates = CoPlan.configuration.user_search.call(query) + local_usernames = CoPlan::User.where(username: candidates.filter_map { |c| c[:username] || c["username"] }).pluck(:username).to_set + candidates.select { |c| local_usernames.include?(c[:username] || c["username"]) } + 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..f702941 100644 --- a/engine/app/helpers/coplan/markdown_helper.rb +++ b/engine/app/helpers/coplan/markdown_helper.rb @@ -14,15 +14,43 @@ 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 }) - sanitized = sanitize(html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) + with_chips = transform_mention_anchors(html) + sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES) result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized tag.div(result.html_safe, class: "markdown-rendered") end + # Replaces `@username` produced by + # Commonmarker with a styled `` chip. Runs on the parsed HTML so + # that mentions inside fenced code blocks or inline code stay as literal + # text — Commonmarker doesn't emit `` 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">
diff --git a/engine/app/views/coplan/comment_threads/_reply_form.html.erb b/engine/app/views/coplan/comment_threads/_reply_form.html.erb index fa4c7d2..3406caa 100644 --- a/engine/app/views/coplan/comment_threads/_reply_form.html.erb +++ b/engine/app/views/coplan/comment_threads/_reply_form.html.erb @@ -2,7 +2,9 @@ <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
+ data-controller="coplan--comment-form" + data-coplan--comment-form-search-url-value="<%= search_users_path %>" + data-action="keydown->coplan--comment-form#submitOnEnter">
<% end %> diff --git a/engine/config/routes.rb b/engine/config/routes.rb index c3eab8f..39d36db 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -27,9 +27,6 @@ namespace :api do namespace :v1 do - resources :users, only: [] do - get :search, on: :collection - end resources :tags, only: [:index] resources :plans, only: [:index, :show, :create, :update] do get :versions, on: :member @@ -54,6 +51,10 @@ end end + resources :users, only: [] do + get :search, on: :collection + end + resources :notifications, only: [:index, :show] do member do patch :mark_read diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 71d1184..39a9e80 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -9,7 +9,8 @@ class Configuration attr_accessor :agent_curl_prefix attr_accessor :seed_plan_types - # Lambda for user search used by the /api/v1/users/search endpoint. + # Lambda for user search used by the /users/search endpoint (typeahead + # for in-app pickers like @-mentions). # Accepts a query string, returns an array of hashes with keys: # :id, :name, :email, :avatar_url, :title, :team # When nil (default), falls back to LIKE search on local coplan_users table. diff --git a/spec/helpers/markdown_helper_spec.rb b/spec/helpers/markdown_helper_spec.rb index 20972ba..d286a05 100644 --- a/spec/helpers/markdown_helper_spec.rb +++ b/spec/helpers/markdown_helper_spec.rb @@ -50,4 +50,39 @@ expect(html).to include("<script>") end end + + describe "@-mention rendering" do + it "renders [@username](mention:username) as a styled chip" do + html = helper.render_markdown("Hey [@hampton](mention:hampton), please look") + expect(html).to include('@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