diff --git a/app/controllers/prompts/users_controller.rb b/app/controllers/prompts/users_controller.rb new file mode 100644 index 000000000..4fbb605e9 --- /dev/null +++ b/app/controllers/prompts/users_controller.rb @@ -0,0 +1,9 @@ +class Prompts::UsersController < ApplicationController + def index + @users = User.all + + if stale? etag: @users + render layout: false + end + end +end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb index 2e0b63a93..273ae2888 100644 --- a/app/helpers/rich_text_helper.rb +++ b/app/helpers/rich_text_helper.rb @@ -3,6 +3,10 @@ module RichTextHelper content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention" end + def global_mentions_prompt + content_tag "lexical-prompt", "", trigger: "@", src: prompts_users_path, name: "mention" + end + def cards_prompt content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true end diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js index efcd9b357..e0b37a788 100644 --- a/app/javascript/controllers/terminal_controller.js +++ b/app/javascript/controllers/terminal_controller.js @@ -41,9 +41,9 @@ export default class extends Controller { this.#hideOutput() } - submitCommand() { - this.formTarget.requestSubmit() - this.#reset() + submitCommand({ target }) { + console.debug("Es", target); + this.#submitCommand() } handleKeyPress(event) { @@ -183,7 +183,12 @@ export default class extends Controller { this.inputTarget.value = this.originalInputValue this.confirmationTarget.value = "confirmed" this.#hideOutput() - this.submitCommand() + this.#submitCommand() + } + + #submitCommand() { + this.formTarget.requestSubmit() + this.#reset() } #showOutput(markdown) { diff --git a/app/models/command/ai/parser.rb b/app/models/command/ai/parser.rb index 6e881449e..9b810c60d 100644 --- a/app/models/command/ai/parser.rb +++ b/app/models/command/ai/parser.rb @@ -42,10 +42,10 @@ class Command::Ai::Parser def resolve_named_params_to_ids(normalized_query) normalized_query.tap do |query_json| if query_context = query_json[:context].presence - query_context[:assignee_ids] = query_context[:assignee_ids]&.filter_map { |name| assignee_from(name)&.id } - query_context[:creator_ids] = query_context[:creator_ids]&.filter_map { |name| assignee_from(name)&.id } - query_context[:collection_ids] = query_context[:collection_ids]&.filter_map { |name| Collection.where("lower(name) like ?", "%#{name.downcase}%").first&.id } - query_context[:tag_ids] = query_context[:tag_ids]&.filter_map { |name| ::Tag.find_by_title(name)&.id } + query_context[:assignee_ids] = query_context[:assignee_ids]&.filter_map { |name| context.find_user(name)&.id } + query_context[:creator_ids] = query_context[:creator_ids]&.filter_map { |name| context.find_user(name)&.id } + query_context[:collection_ids] = query_context[:collection_ids]&.filter_map { |name| context.find_collection(name)&.id } + query_context[:tag_ids] = query_context[:tag_ids]&.filter_map { |name| context.find_tag(name)&.id } query_context.compact! end end diff --git a/app/models/command/ai/translator.rb b/app/models/command/ai/translator.rb index ffe91f14e..914e7e95a 100644 --- a/app/models/command/ai/translator.rb +++ b/app/models/command/ai/translator.rb @@ -145,8 +145,10 @@ class Command::Ai::Translator * If cards are described as state ("assigned to X") and later an action ("assign X"), only the first is a filter. * ❗ Once you produce a valid context **or** command list, do not add a fallback /search. - -------------------- COMMAND INTERPRETATION RULES -------------------- + ---------------------- RESOLVE COMMAND ARGUMENTS ---------------------- + * A person can be express by its name or via a global ID URL like gid://fizzy/User/773524000?tenant=37signals. + -------------------- COMMAND INTERPRETATION RULES -------------------- * /user → open that person’s profile or activity feed. – Phrases like “visit user ”, “view user ”, “see ’s profile” must map to **/user**, **never** to /visit. * /visit → open any other URL or internal path (cards, settings, etc.). diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb index d45cd69ed..b6aaac813 100644 --- a/app/models/command/parser.rb +++ b/app/models/command/parser.rb @@ -8,14 +8,24 @@ class Command::Parser end def parse(string) - parse_command(string).tap do |command| + parseable_command = as_plain_text_with_attachable_references(string) + + parse_command(parseable_command).tap do |command| command.user = user - command.line ||= string + command.line ||= as_plain_text(string) command.context ||= context end end private + def as_plain_text_with_attachable_references(string) + ActionText::Content.new(string).render_attachments(&:to_gid).fragment.to_plain_text + end + + def as_plain_text(string) + ActionText::Content.new(string).to_plain_text + end + def parse_command(string) command_name, *command_arguments = string.strip.split(" ") combined_arguments = command_arguments.join(" ") @@ -24,9 +34,9 @@ class Command::Parser when /^#/ Command::FilterByTag.new(tag_title: tag_title_from(string), params: filter.as_params) when /^@/ - Command::GoToUser.new(user_id: assignee_from(command_name)&.id) + Command::GoToUser.new(user_id: context.find_user(command_name)&.id) when "/user" - Command::GoToUser.new(user_id: assignee_from(combined_arguments)&.id) + Command::GoToUser.new(user_id: context.find_user(combined_arguments)&.id) when "/assign", "/assignto" Command::Assign.new(assignee_ids: assignees_from(command_arguments).collect(&:id), card_ids: cards.ids) when "/clear" @@ -44,7 +54,7 @@ class Command::Parser when "/search" Command::Search.new(terms: combined_arguments) when "/stage" - Command::Stage.new(stage_id: stage_from(combined_arguments)&.id, card_ids: cards.ids) + Command::Stage.new(stage_id: context.find_workflow_stage(combined_arguments)&.id, card_ids: cards.ids) when "/visit" Command::VisitUrl.new(url: command_arguments.first) when "/tag" @@ -57,20 +67,7 @@ class Command::Parser private def assignees_from(strings) Array(strings).filter_map do |string| - assignee_from(string) - end - end - - # TODO: This is temporary as it can be ambiguous. We should inject the user ID in the command - # under the hood instead, as determined by the user picker. E.g: @1234. - def assignee_from(string) - string_without_at = string.delete_prefix("@") - User.all.find { |user| user.mentionable_handles.include?(string_without_at.downcase) } - end - - def stage_from(combined_arguments) - candidate_stages.find do |stage| - stage.name.downcase.include?(combined_arguments.downcase) + context.find_user(string) end end @@ -78,10 +75,6 @@ class Command::Parser cards.first&.collection || Collection.first end - def candidate_stages - Workflow::Stage.where(workflow_id: cards.joins(:collection).select("collections.workflow_id").distinct) - end - def tag_title_from(string) string.gsub(/^#/, "") end diff --git a/app/models/command/parser/context.rb b/app/models/command/parser/context.rb index 401efbd98..6c2c93d5a 100644 --- a/app/models/command/parser/context.rb +++ b/app/models/command/parser/context.rb @@ -28,6 +28,29 @@ class Command::Parser::Context viewing_cards_index? || viewing_search_results? end + def find_user(string) + if string.starts_with?("gid://") + User.find_by_id(GlobalID::Locator.locate(string).id) + else + string_without_at = string.delete_prefix("@") + User.all.find { |user| user.mentionable_handles.include?(string_without_at.downcase) } + end + end + + def find_workflow_stage(string) + candidate_stages.find do |stage| + stage.name.downcase.include?(combined_arguments.downcase) + end + end + + def find_tag(string) + Tag.find_by_title(string) + end + + def find_collection(string) + Collection.where("lower(name) like ?", "%#{name.downcase}%").first + end + private attr_reader :controller, :action, :params @@ -57,4 +80,8 @@ class Command::Parser::Context @action = route[:action] @params = ActionController::Parameters.new(Rack::Utils.parse_nested_query(uri.query).merge(route.except(:controller, :action))) end + + def candidate_stages + Workflow::Stage.where(workflow_id: cards.joins(:collection).select("collections.workflow_id").distinct) + end end diff --git a/app/models/user.rb b/app/models/user.rb index 40d6116c3..fb5566e29 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,6 +26,11 @@ class User < ApplicationRecord update! active: false, email_address: deactived_email_address end + # TODO: Move to attachable along with the concern + def attachable_plain_text_representation(...) + "@#{first_name.downcase}" + end + private def deactived_email_address email_address.sub(/@/, "-deactivated-#{SecureRandom.uuid}@") diff --git a/app/views/commands/_form.html.erb b/app/views/commands/_form.html.erb index b675aedc4..977e76419 100644 --- a/app/views/commands/_form.html.erb +++ b/app/views/commands/_form.html.erb @@ -17,7 +17,9 @@ action: "keydown.enter->terminal#submitCommand keydown.up->toggle-class#add:prevent keydown.up->navigable-list#selectCurrentOrReset:stop terminal#hideError" }, placeholder: platform.desktop? ? "Press #{ hotkey_label(["ctrl", "K"]) } to search or type commands…" : "Search or type commands…", - spellcheck: "false" %> + spellcheck: "false" do %> + <%= global_mentions_prompt %> + <% end %> <%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %> <% end %> diff --git a/app/views/prompts/users/index.html.erb b/app/views/prompts/users/index.html.erb new file mode 100644 index 000000000..3ece68eab --- /dev/null +++ b/app/views/prompts/users/index.html.erb @@ -0,0 +1 @@ +<%= render partial: "prompts/collections/users/user", collection: @users %>