Support user prompts in Fizzy do, move parsing logic to context

This commit is contained in:
Jorge Manrubia
2025-07-01 14:06:39 +02:00
parent 0f7b45ab38
commit 93be9e945f
10 changed files with 81 additions and 33 deletions
@@ -0,0 +1,9 @@
class Prompts::UsersController < ApplicationController
def index
@users = User.all
if stale? etag: @users
render layout: false
end
end
end
+4
View File
@@ -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
@@ -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) {
+4 -4
View File
@@ -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
+3 -1
View File
@@ -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 <Name> → open that persons profile or activity feed.
Phrases like “visit user <Name>”, “view user <Name>”, “see <Name>s profile” must map to **/user**, **never** to /visit.
* /visit <url|path> → open any other URL or internal path (cards, settings, etc.).
+16 -23
View File
@@ -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
+27
View File
@@ -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
+5
View File
@@ -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}@")
+3 -1
View File
@@ -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 %>
+1
View File
@@ -0,0 +1 @@
<%= render partial: "prompts/collections/users/user", collection: @users %>