diff --git a/app/controllers/commands_controller.rb b/app/controllers/commands_controller.rb index ccb187cc3..00a1bec42 100644 --- a/app/controllers/commands_controller.rb +++ b/app/controllers/commands_controller.rb @@ -1,6 +1,6 @@ class CommandsController < ApplicationController def index - @commands = Current.user.commands.order(created_at: :desc).limit(20).reverse + @commands = Current.user.commands.root.order(created_at: :desc).limit(20).reverse end def create @@ -15,7 +15,7 @@ class CommandsController < ApplicationController respond_with_needs_confirmation(command) end else - respond_with_error + respond_with_error(command) end end @@ -38,10 +38,10 @@ class CommandsController < ApplicationController def respond_with_execution_result(result) case result + when Array + respond_with_composite_response(result) when Command::Result::Redirection redirect_to result.url - when Command::Result::ChatResponse - respond_with_chat_response(result) when Command::Result::InsightResponse respond_with_insight_response(result) else @@ -49,42 +49,20 @@ class CommandsController < ApplicationController end end - def respond_with_chat_response(result) - command = command_from_chat_response(result) - - if confirmed?(command) - if result.has_context_url? && params["redirected"].blank? - respond_with_needs_redirection redirect_to: result.context_url - elsif command.valid? - chat_response_result = command.execute - respond_with_execution_result chat_response_result - else - respond_with_error - end - else - respond_with_needs_confirmation(command.commands, redirect_to: result.context_url) - end + def respond_with_needs_confirmation(command) + render json: { confirmation: command.confirmation_prompt, redirect_to: command.context.url }, status: :conflict end - def respond_with_needs_confirmation(commands, redirect_to: nil) - render json: { commands: Array(commands).collect(&:title), redirect_to: redirect_to }, status: :conflict - end - - def respond_with_needs_redirection(redirect_to:) - render json: { redirect_to: redirect_to }, status: :conflict - end - - def command_from_chat_response(chat_response) - context = Command::Parser::Context.new(Current.user, url: chat_response.context_url || request.referrer) - parser = Command::Parser.new(context) - Command::Composite.new(chat_response.command_lines.collect { parser.parse it }) + def respond_with_composite_response(results) + json = results.map(&:as_json).grep(Hash).reduce({}, :merge) + render json: json, status: :accepted end def respond_with_insight_response(chat_response) render json: { message: chat_response.content }, status: :accepted end - def respond_with_error - head :unprocessable_entity + def respond_with_error(command) + render json: { error: command.errors.full_messages.join(", "), context_url: command.context.url }, status: :unprocessable_entity end end diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js index 58d2a735e..b575225bb 100644 --- a/app/javascript/controllers/terminal_controller.js +++ b/app/javascript/controllers/terminal_controller.js @@ -3,13 +3,12 @@ import { HttpStatus } from "helpers/http_helpers" import { marked } from "marked" export default class extends Controller { - static targets = [ "input", "form", "output", "confirmation", "redirected" ] + static targets = [ "input", "form", "output", "confirmation" ] static classes = [ "error", "confirmation", "help", "output", "busy" ] - static values = { originalInput: String, waitingForConfirmation: Boolean, autoSubmitAfterRedirection: Boolean } + static values = { originalInput: String, waitingForConfirmation: Boolean } connect() { if (this.waitingForConfirmationValue) { this.focus() } - if (this.autoSubmitAfterRedirectionValue) { this.#submitAfterRedirection() } } // Actions @@ -87,7 +86,7 @@ export default class extends Controller { #handleSuccessResponse(response) { if (response.headers.get("Content-Type")?.includes("application/json")) { response.json().then((responseJson) => { - this.#showOutput(marked.parse(responseJson.message)) + this.#handleJsonResponse(responseJson) }) } this.#reset() @@ -106,10 +105,8 @@ export default class extends Controller { #reset(inputValue = "") { this.inputTarget.value = inputValue this.confirmationTarget.value = "" - this.redirectedTarget.value = "" this.waitingForConfirmationValue = false this.originalInputValue = null - this.autoSubmitAfterRedirectionValue = false this.element.classList.remove(this.errorClass) this.element.classList.remove(this.confirmationClass) @@ -121,44 +118,32 @@ export default class extends Controller { } async #handleConflictResponse(response) { - const { commands, redirect_to } = await response.json() - this.originalInputValue = this.inputTarget.value + this.#handleJsonResponse(await response.json()) + } - if (commands && commands.length) { - this.#requestConfirmation(commands) - } else { - this.autoSubmitAfterRedirectionValue = true + #handleJsonResponse(responseJson) { + const { confirmation, message, redirect_to } = responseJson + + if (message) { + this.#showOutput(marked.parse(message)) } + + if (confirmation) { + this.#requestConfirmation(confirmation) + } + if (redirect_to) { - Turbo.visit(redirect_to, { frame: "cards" }) + Turbo.visit(redirect_to) } } - async #requestConfirmation(commands) { + async #requestConfirmation(confirmationPrompt) { this.element.classList.add(this.confirmationClass) - - if (commands.length > 1) { - this.#requestMultiLineConfirmation(commands) - } else { - this.#requestSingleLineConfirmation(commands) - } - + this.inputTarget.value = `${confirmationPrompt}? [Y/n] ` this.waitingForConfirmationValue = true } - #requestMultiLineConfirmation(commands) { - const commandsDescription = commands.map(command => `- ${command}`).join(`
`) - const message = `This will:
${commandsDescription}` - this.#showOutput(message) - this.inputTarget.value = `Do you confirm? [Y/n] ` - } - - #requestSingleLineConfirmation(commands) { - const message = commands[0] - this.inputTarget.value = `${message}? [Y/n] ` - } - #handleConfirmationKey(key) { if (key === "enter" || key === "y") { this.#submitWithConfirmation() @@ -176,12 +161,6 @@ export default class extends Controller { this.#reset() } - #submitAfterRedirection() { - this.inputTarget.value = this.originalInputValue - this.redirectedTarget.value = "redirected" - this.formTarget.requestSubmit() - } - #showOutput(html) { this.element.classList.add(this.outputClass) this.outputTarget.innerHTML = html diff --git a/app/models/command.rb b/app/models/command.rb index cd93bbe1b..4d9b201bd 100644 --- a/app/models/command.rb +++ b/app/models/command.rb @@ -2,6 +2,9 @@ class Command < ApplicationRecord include Rails.application.routes.url_helpers belongs_to :user + belongs_to :parent, class_name: "Command", optional: true + + scope :root, ->{ where(parent_id: nil) } attribute :context @@ -9,6 +12,10 @@ class Command < ApplicationRecord model_name.human end + def confirmation_prompt + title + end + def execute end diff --git a/app/models/command/ai/parser.rb b/app/models/command/ai/parser.rb new file mode 100644 index 000000000..64d1f85fa --- /dev/null +++ b/app/models/command/ai/parser.rb @@ -0,0 +1,65 @@ +class Command::Ai::Parser + include Rails.application.routes.url_helpers + + attr_reader :context + + delegate :user, to: :context + + def initialize(context) + @context = context + end + + def parse(query) + query_json = resolve_named_params_to_ids command_translator.as_normalized_json(query) + build_chat_response_with query, query_json + end + + private + def command_translator + Command::Ai::Translator.new(context) + end + + def resolve_named_params_to_ids(query_json) + query_json.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_id"] = assignee_from(query_context["creator_id"])&.id if query_context["creator_id"] + query_context["collection_ids"] = query_context["collection_ids"]&.filter_map { |name| Collection.where("lower(name) = ?", name.downcase).first&.id } + query_context["tag_ids"] = query_context["tag_ids"]&.filter_map { |name| ::Tag.find_by_title(name)&.id } + query_context.compact! + end + end + end + + def assignee_from(string) + string_without_at = string.delete_prefix("@") + User.all.find { |user| user.mentionable_handles.include?(string_without_at) } + end + + def build_chat_response_with(query, query_json) + query_context = context_from_query(query_json) + resolved_context = query_context || context + + commands = Array.wrap(commands_from_query(query_json, resolved_context)) + + if query_context + commands.unshift Command::VisitUrl.new(user: user, url: query_context.url, context: resolved_context) + end + + Command::Composite.new(title: query, commands: commands, user: user, line: query, context: resolved_context) + end + + def context_from_query(query_json) + if context_properties = query_json["context"].presence + url = cards_path(**context_properties) + Command::Parser::Context.new(user, url: url) + end + end + + def commands_from_query(query_json, context) + parser = Command::Parser.new(context) + if command_lines = query_json["commands"].presence + command_lines.collect { parser.parse(it) } + end + end +end diff --git a/app/models/command/ai/translator.rb b/app/models/command/ai/translator.rb new file mode 100644 index 000000000..b58fcb1cc --- /dev/null +++ b/app/models/command/ai/translator.rb @@ -0,0 +1,148 @@ +class Command::Ai::Translator + attr_reader :context + + def initialize(context) + @context = context + end + def as_normalized_json(query) + response = chat.ask query + Rails.logger.info "*** Commands: #{response.content}" + compact JSON.parse(response.content) + end + + private + def chat + chat = ::RubyLLM.chat + chat.with_instructions(prompt) + end + + def prompt + <<~PROMPT + You are Fizzy’s command translator. Given a request by the user you should: + + 1. Read the user’s request + 2. Consult the current view + 3. Determine if you need a new context to resolve the query or if the current view is enough. + 4. Create as many commands as you need to satisfy the request. + 5. Output a JSON object that contains: + * The new context properties, when a new context is needed. + * A **single JSON array** of command objects to execute + + Fizzy data includes cards and comments contained in those. A card can represent an issue, a feature, + a bug, a task, etc. Cards are contained in collections. + + ## Current view: + + The user is currently #{context_description} }. + + ## Determine context + + If the query seems to refer to filtering certain cards, try to satisfy the filter with the supported options: + + * terms: a list of terms to search for. Use this option to refine searches based on further keyword*based + queries. Pass an array even when it's only one term. Always send individual terms separated by spaces. + E.g: ["some", "term"] instead of ["some term"]. + * indexed_by: can be "newest", "oldest", "latest", "stalled", "closed" + * assignee_ids: the name of the person or persons assigned to the card. + * assignment_status: only used to filter unassigned cards with "unassigned". + * engagement_status: can be "considering" or "doing". This refers to whether the team is working on something. + * card_ids: a list of card ids + * creator_id: the name of a person + * collection_ids: a list of collection names. Cards are contained in collections. Don't use unless mentioning + specific collections. + * tag_ids: a list of tag names. + + What to use to filter: + + - To filter cards assigned to people, use "assignee_ids" and pass the name of the person or persons. + - To filter cards created by someone, use "creator_id". + - To filter cards with certain tags, use "tag_ids" and pass the name of the tag or tags. + - To filter unassigned cards, use "assignment_status" and pass "unassigned". + - To filter cards by certain subject, use "terms" to pass relevant keywords. Avoid generic keywords, + extract the relevant ones. + + If a new context is needed, the output json will contain a "context"" property with the required properties: + + Example: { context: { terms: ["design", "title"] }, assignee_ids: ["jz"], tag_ids: ["design"], commands: [.....] } + + When the user is in a view "not seeing cards", then always create a context to satisfy the request. + + If the query does not refer to filtering certain cards, the output won't contain any context key. Notice there is already a + context in the current view, and, unless the request indicate otherwise, that's the context you should assume. + + If you can't infer clear commands or filtering conditions, just create a context that searches using "terms" derived from + the query. + + ## Supported commands: + + A command is represented with simple string that contains the command preffixed with / and may contain additional params + separated by spaces. The supported commands are: + + - Assign users to cards: Syntax: /assign [user]. The user can be prefixed with @. Example: "/assign kevin" or "/assign @kevin" + - Close cards: Syntax: /close [optional reason]. Example: "/close" or "/close not now" + - Tag cards with certain one or multiple tags: Syntax: /tag [tag-name]. The tag can be prefixed with #. Example: "/tag performance" or "/tag #peformance" + - Clear filters: Syntax: /clear + - Get AI insight about cards: Syntax: /insight [query]. Example: "/insight summarize". Notice that this can be combined with + creating a new context to get insight from. + + Notice that commands can be combined with filtering to specify the context. For example: "close cards assigned to jorge" should + generate a context to search cards assigned to jorge and a /close command to act on those. + + Some queries simply require creating a new context and not running any command. For example "cards assigned to jorge", + "issues about headlines tagged with #design" are mere filtering requests that are satisfied by creating a new context. Don't +' add a "commands" property with an empty array, just avoid the property in these cases. + + When you do need to create new commands, append them to the JSON under the "commands" property: + + Example: { commands: [ "/assign jorge", "/insight summarize performance issues" ] } + + Notice that commands and context can be combined if needed: + + Example: { context: { terms: ["design", "title"] }, commands: [ "/assign jorge" ] } + + Make sure you create as many commands as you need to satisfy the request. + + ## JSON format + + Each command will be a JSON object containing two properties: "commands" and "context". Notice that both are optional + but at least ONE must be present. All these examples are valid: + + { context: [ terms: [ "performance" ] ], commands: ["/assign jorge", "/close"] } + { context: [ terms: [ "performance" ] ] } + { commands: [ "/assign jorge", "/close"] } + + # Other + + * Avoid empty preambles like "Based on the provided cards". Be friendly, favor an active voice. + * Be concise and direct. + * When emitting search commands, if searching for terms, remove generic ones. + * Don't use a "terms" filter for expressions added as other context properties or commands. E.g: if filtering cards assigned to + "jorge"", don't filter by terms "jorge" too. If tagging with "design", don't filter by the term "design" too. + for those terms too. If assigning a tag, don't search that tag too. + * An unassigned card is a card without assignees. + * Never create a /search or /insight without additional params. + * An unassigned card can be closed or not. "unassigned" and "closed" are different unrelated concepts. + * An unassigned card can be "considering" or "doing". "unassigned" and "engagement_status" are different unrelated concepts. + * Only use assignment_status asking for unassigned cards. Never use in other circumstances. + PROMPT + end + + def context_description + if context.viewing_card_contents? + "inside a card" + elsif context.viewing_list_of_cards? + "viewing a list of cards" + else + "not seeing cards" + end + end + + def compact(json) + context = json["context"] + context&.each do |key, value| + context[key] = value.presence + end + context&.compact! + json.compact + end +end diff --git a/app/models/command/chat_query.rb b/app/models/command/chat_query.rb deleted file mode 100644 index 36526ed3b..000000000 --- a/app/models/command/chat_query.rb +++ /dev/null @@ -1,153 +0,0 @@ -class Command::ChatQuery < Command - store_accessor :data, :query, :params - - def title - "Chat query '#{query}'" - end - - def execute - response = chat.ask query - Rails.logger.info "*** Commands: #{response.content}" - generated_commands = replace_names_with_ids(JSON.parse(response.content)) - build_chat_response_with generated_commands - end - - private - def chat - chat = RubyLLM.chat - chat.with_instructions(prompt) - end - - # TODO: - # - Don't generate initial /search if not requested. "Assign to JZ" should - def prompt - <<~PROMPT - You are Fizzy’s command translator. Read the user’s request, consult the current view, and output - a **single JSON array** of command objects. Return nothing except that JSON. - - Fizzy data includes cards and comments contained in those. A card can represent an issue, a feature, - a bug, a task, etc. - - ## Current context: - - The user is currently #{context.viewing_card_contents? ? 'inside a card' : 'viewing a list of cards' }. - - ## Supported commands: - - - Assign users to cards: Syntax: /assign [user]. Example: "/assign kevin" - - Close cards: Syntax: /close [optional reason]. Example: "/close" or "/close not now" - - Tag cards: Syntax: /tag [tag-name]. Example: "/tag performance" - - Clear filters: Syntax: /clear - - Get insight about cards: Syntax: /insight [query]. Example: "/insight summarize performance issues". - - Search cards based on certain keywords: Syntax: /search. It supports the following parameters: - * assignment_status: only used to filter unassigned cards with "unassigned". - * terms: a list of terms to search for. Use this option to refine searches based on further keyword*based - queries. Use the plural terms even when it's only one term. Always send individual terms separated by spaces. - E.g: ["some", "term"] instead of ["some term"]. - * indexed_by: can be "newest", "oldest", "latest", "stalled", "closed" - * engagement_status: can be "considering" or "doing". This refers to whether the team is working on something. - * card_ids: a list of card ids - * assignee_ids: a list of assignee names - * creator_id: the name of a person - * collection_ids: a list of collection names. Cards are contained in collections. Don't use unless mentioning - specific collections. - * tag_ids: a list of tag names. - - ## How to translate requests into commands - - 1. Determine if you have the right context based on the "current context": - - If it is is "inside a card", assume you are in the right context. - - If it is "viewing a list of cards": - a) consider emitting a /search command to filter the cards. - b) consider emitting also a /insight command to refine context if needed. Don't do this when just asking for certain - terms, only when the request justifies it. Pass the original query verbatim - to insight as the [query]. If the query is "why is it taking so long?", add "/insight why is it taking so long?". - - 2. Create the sequence of commands to satisfy the user's request. - - If the request is about answering some question about cards, add an /insight command. You can only - add ONE /insight command in total. - - If it is "viewing a list of cards", before emitting the /insight command, consider emitting a /search command - to create the right context to extract the insight from. - - If the request requires acting on cards, add the sequence of commands that satisfy those. You can combine - all of them except /search and /insight, which have an special consideration. - - ## How to filter cards - - - Find cards closed by someone with: (1) /search with indexed_by=closed and assignee_id=someone". - - When asking for assigned cards, use assignee_ids not assignment_status. - - If it's not clear what the user is asking for, perform a /search passing the original query as terms. E.g: for - "red car" add { command: "/search", terms: ["red", "car"] }. - - ## JSON format - - Each command will be a JSON object like. All the commands JSON objects a "command" key with the command. - - { command: "/close" } - - The "/search"" command can contain additional keys for the params in the JSON: - - { command: "/search", indexed_by: "closed", collection_ids: [ "Writebook", "Design" ] } - - The rest of commands will only have a "command" key, nothing else. - - The output will be a single list of JSON objects. Make sure to place values in double quotes and - that you generate valid JSON. Always respond with a list like [ { }, { }, ...] - - # Other - - * Avoid empty preambles like "Based on the provided cards". Be friendly, favor an active voice. - * Be concise and direct. - * When emitting search commands, if searching for terms, remove generic ones. - * The response can't contain more than one /search command. - * The response can't contain more than one /insight command. - * An unassigned card is a card without assignees. - * Never create a /search or /insight without additional params. - * An unassigned card can be closed or not. "unassigned" and "closed" are different unrelated concepts. - * An unassigned card can be "considering" or "doing". "unassigned" and "engagement_status" are different unrelated concepts. - * Only use assignment_status asking for unassigned cards. Never use in other circumstances. - * There are similar commands to filter and act on cards (e.g: filter by assignee or assign - cards). Favor filtering/queries for commands like "cards assigned to someone". - PROMPT - end - - def replace_names_with_ids(commands) - commands.each do |command| - if command["command"] == "/search" - command["assignee_ids"] = command["assignee_ids"]&.filter_map { |name| assignee_from(name)&.id } - command["creator_id"] = assignee_from(command["creator_id"])&.id if command["creator_id"] - command["collection_ids"] = command["collection_ids"]&.filter_map { |name| Collection.where("lower(name) = ?", name.downcase).first&.id } - command["tag_ids"] = command["tag_ids"]&.filter_map { |name| ::Tag.find_by_title(name)&.id } - command.compact! - end - end - end - - def assignee_from(string) - string_without_at = string.delete_prefix("@") - User.all.find { |user| user.mentionable_handles.include?(string_without_at) } - end - - def build_chat_response_with(generated_commands) - Command::Result::ChatResponse.new \ - command_lines: response_command_lines_from(generated_commands), - context_url: response_context_url_from(generated_commands) - end - - def response_command_lines_from(generated_commands) - # We translate standalone /search commands as redirections to execute. Otherwise, they - # will be excluded out from the commands to run, as they represent the context url. - # - # TODO: Tidy up this. - if generated_commands.size == 1 && generated_commands.find { it["command"] == "/search" } - [ "/visit #{cards_path(**generated_commands.first.without("command"))}" ] - else - generated_commands.filter { it["command"] != "/search" }.collect { it["command"] } - end - end - - def response_context_url_from(generated_commands) - if generated_commands.size > 1 && search_command = generated_commands.find { it["command"] == "/search" } - cards_path(**search_command.without("command")) - end - end -end diff --git a/app/models/command/composite.rb b/app/models/command/composite.rb index 7efcc4e47..19bd62882 100644 --- a/app/models/command/composite.rb +++ b/app/models/command/composite.rb @@ -1,21 +1,13 @@ # A composite of commands -class Command::Composite - attr_reader :commands +class Command::Composite < Command + store_accessor :data, :title - def initialize(commands) - @commands = commands - end - - def title - @commands.collect(&:title).join(", ") - end + has_many :commands, inverse_of: :parent, dependent: :destroy def execute - result = nil ApplicationRecord.transaction do - commands.each { result = it.execute } + commands.collect { it.execute } end - result end def undo @@ -24,16 +16,24 @@ class Command::Composite end end + def undoable? + undoable_commands.any? + end + + def confirmation_prompt + commands_excluding_redirections.collect(&:confirmation_prompt).to_sentence + end + def needs_confirmation? commands.any?(&:needs_confirmation?) end - def valid? - commands.all?(&:valid?) - end - private + def commands_excluding_redirections + commands.reject { it.is_a?(Command::VisitUrl) } + end + def undoable_commands - commands.filter(&:undoable?) + @undoable_commands ||= commands.filter(&:undoable?) end end diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb index 4fe0e62e8..c37a7a174 100644 --- a/app/models/command/parser.rb +++ b/app/models/command/parser.rb @@ -10,8 +10,8 @@ class Command::Parser def parse(string) parse_command(string).tap do |command| command.user = user - command.line = string - command.context = context + command.line ||= string + command.context ||= context end end @@ -65,7 +65,7 @@ class Command::Parser elsif card = single_card_from(string) Command::GoToCard.new(card_id: card.id) else - Command::ChatQuery.new(query: string, params: filter.as_params) + Command::Ai::Parser.new(context).parse(string) end end diff --git a/app/models/command/parser/context.rb b/app/models/command/parser/context.rb index 59dc81512..3624ba43d 100644 --- a/app/models/command/parser/context.rb +++ b/app/models/command/parser/context.rb @@ -1,9 +1,11 @@ class Command::Parser::Context - attr_reader :user + attr_reader :user, :url def initialize(user, url:) @user = user - extract_url_components(url) + @url = url + + extract_url_components end def cards @@ -31,7 +33,7 @@ class Command::Parser::Context private attr_reader :controller, :action, :params - def extract_url_components(url) + def extract_url_components uri = URI.parse(url || "") route = Rails.application.routes.recognize_path(uri.path) @controller = route[:controller] diff --git a/app/models/command/result/chat_response.rb b/app/models/command/result/chat_response.rb deleted file mode 100644 index d6a0cd2e3..000000000 --- a/app/models/command/result/chat_response.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Command::Result::ChatResponse - attr_reader :command_lines, :context_url - - def initialize(context_url: nil, command_lines:) - @context_url = context_url - @command_lines = command_lines - end - - def has_context_url? - context_url.present? - end -end diff --git a/app/models/command/result/insight_response.rb b/app/models/command/result/insight_response.rb index ebf76ea75..6eb9455bb 100644 --- a/app/models/command/result/insight_response.rb +++ b/app/models/command/result/insight_response.rb @@ -1 +1,11 @@ -Command::Result::InsightResponse = Struct.new(:content) +class Command::Result::InsightResponse + attr_reader :content + + def initialize(content) + @content = content + end + + def as_json + { message: content } + end +end diff --git a/app/models/command/result/redirection.rb b/app/models/command/result/redirection.rb index 3fd5b9b8d..3796558e5 100644 --- a/app/models/command/result/redirection.rb +++ b/app/models/command/result/redirection.rb @@ -1 +1,11 @@ -Command::Result::Redirection = Struct.new(:url) +class Command::Result::Redirection + attr_reader :url + + def initialize(url) + @url = url + end + + def as_json + { redirect_to: url } + end +end diff --git a/app/views/commands/_form.html.erb b/app/views/commands/_form.html.erb index aff453c9e..8b493dfab 100644 --- a/app/views/commands/_form.html.erb +++ b/app/views/commands/_form.html.erb @@ -18,9 +18,9 @@ action: "keydown.up->toggle-class#add:prevent keydown.up->navigable-list#selectCurrentOrLast terminal#hideError" }, placeholder: "Press ⌘+K to search or type /commands…", + turbo_permanent: true, spellcheck: "false" %> <%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %> - <%= hidden_field_tag "redirected", nil, data: { terminal_target: "redirected" } %> <% end %> diff --git a/app/views/commands/_terminal.html.erb b/app/views/commands/_terminal.html.erb index 02cf6188b..4a2a76e9f 100644 --- a/app/views/commands/_terminal.html.erb +++ b/app/views/commands/_terminal.html.erb @@ -9,7 +9,6 @@ terminal_output_class: "terminal--showing-output", terminal_busy_class: "terminal--busy", toggle_class_toggle_class: "terminal--open", - turbo_permanent: true, action: "keydown->terminal#handleKeyPress keydown->navigable-list#navigate focusin->navigable-list#select keydown.esc->toggle-class#remove:stop keydown.esc->terminal#focus keydown.esc->navigable-list#selectLast keydown.esc@document->terminal#hideMenus" } do %> <%= turbo_frame_tag :recent_commands, src: commands_path, refresh: "morph" %> diff --git a/db/migrate/20250515125505_add_parent_column_to_commands.rb b/db/migrate/20250515125505_add_parent_column_to_commands.rb new file mode 100644 index 000000000..decd29a52 --- /dev/null +++ b/db/migrate/20250515125505_add_parent_column_to_commands.rb @@ -0,0 +1,6 @@ +class AddParentColumnToCommands < ActiveRecord::Migration[8.1] + def change + add_reference :commands, :parent, null: true, foreign_key: { to_table: :commands } + add_index :commands, %i[ user_id parent_id created_at ] + end +end diff --git a/db/schema.rb b/db/schema.rb index 5cd0c8e30..834b2bcc9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do +ActiveRecord::Schema[8.1].define(version: 2025_05_15_125505) do create_table "accesses", force: :cascade do |t| t.integer "collection_id", null: false t.datetime "created_at", null: false @@ -163,10 +163,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do t.datetime "created_at", null: false t.json "data", default: {} t.text "line" + t.integer "parent_id" t.string "type" t.datetime "updated_at", null: false t.integer "user_id", null: false + t.index ["parent_id"], name: "index_commands_on_parent_id" t.index ["user_id", "created_at"], name: "index_commands_on_user_id_and_created_at" + t.index ["user_id", "parent_id", "created_at"], name: "index_commands_on_user_id_and_parent_id_and_created_at" t.index ["user_id"], name: "index_commands_on_user_id" end @@ -342,6 +345,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_05_10_034936) do add_foreign_key "closures", "cards" add_foreign_key "closures", "users" add_foreign_key "collections", "workflows" + add_foreign_key "commands", "commands", column: "parent_id" add_foreign_key "commands", "users" add_foreign_key "comments", "cards" add_foreign_key "events", "collections" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 63c05a1e2..fbaacdc9c 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -563,6 +563,16 @@ columns: default_function: collation: comment: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: parent_id + cast_type: *1 + sql_type_metadata: *2 + 'null': true + default: + default_function: + collation: + comment: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: type @@ -1527,6 +1537,22 @@ indexes: comment: valid: true commands: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: commands + name: index_commands_on_parent_id + unique: false + columns: + - parent_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: commands name: index_commands_on_user_id @@ -1560,6 +1586,24 @@ indexes: nulls_not_distinct: comment: valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: commands + name: index_commands_on_user_id_and_parent_id_and_created_at + unique: false + columns: + - user_id + - parent_id + - created_at + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true comments: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: comments @@ -2117,4 +2161,4 @@ indexes: comment: valid: true workflows: [] -version: 20250510034936 +version: 20250515125505 diff --git a/test/models/command/chat_query_test.rb b/test/models/command/chat_query_test.rb new file mode 100644 index 000000000..40133a4f4 --- /dev/null +++ b/test/models/command/chat_query_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class Command::ChatQueryTest < ActionDispatch::IntegrationTest + include CommandTestHelper + + setup do + Current.session = sessions(:david) + end + + test "sandbox list of cards" do + user = users(:david) + url = cards_url + context = Command::Parser::Context.new(user, url: url) + puts Command::ChatQuery.new(user: user, context: context, query: "summarize cards about performance").execute + puts Command::ChatQuery.new(user: user, context: context, query: "tag with #performance").execute + puts Command::ChatQuery.new(user: user, context: context, query: "cards assigned to jorge").execute + puts Command::ChatQuery.new(user: user, context: context, query: "performance cards assigned to jorge").execute + puts Command::ChatQuery.new(user: user, context: context, query: "close performance cards assigned to jorge and tag them with #performance").execute + end + + test "sandbox single card" do + user = users(:david) + url = card_url(cards(:logo)) + context = Command::Parser::Context.new(user, url: url) + puts Command::ChatQuery.new(user: user, context: context, query: "summarize cards about performance").execute + puts Command::ChatQuery.new(user: user, context: context, query: "tag with performance and close").execute + puts Command::ChatQuery.new(user: user, context: context, query: "summarize this card").execute + puts Command::ChatQuery.new(user: user, context: context, query: "tag with #performance").execute + puts Command::ChatQuery.new(user: user, context: context, query: "cards assigned to jorge").execute + puts Command::ChatQuery.new(user: user, context: context, query: "performance cards assigned to jorge").execute + puts Command::ChatQuery.new(user: user, context: context, query: "close performance cards assigned to jorge and tag them with #performance").execute + end +end