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