support_desk 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +126 -0
  3. data/README.md +1101 -22
  4. data/Rakefile +16 -0
  5. data/app/assets/stylesheets/support_desk.css +10 -0
  6. data/app/controllers/support_desk/tickets_controller.rb +28 -4
  7. data/app/helpers/support_desk/engine_helper.rb +23 -1
  8. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  9. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  10. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  11. data/app/views/support_desk/console/tickets/_context_card.html.erb +33 -0
  12. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  13. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  14. data/app/views/support_desk/console/tickets/_new_conversation_form.html.erb +91 -0
  15. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +28 -0
  16. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  17. data/app/views/support_desk/console/tickets/index.html.erb +14 -2
  18. data/app/views/support_desk/console/tickets/new.html.erb +25 -0
  19. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  20. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  21. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  22. data/config/console_routes.rb +1 -1
  23. data/config/locales/support_desk.console.en.yml +89 -0
  24. data/config/locales/support_desk.console.es.yml +91 -0
  25. data/config/locales/support_desk.en.yml +18 -0
  26. data/config/locales/support_desk.es.yml +29 -0
  27. data/config/routes.rb +7 -1
  28. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  29. data/lib/generators/support_desk/console_generator.rb +1 -1
  30. data/lib/generators/support_desk/install_generator.rb +26 -0
  31. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  32. data/lib/generators/support_desk/templates/add_opened_by_to_support_desk_tickets.rb.erb +79 -0
  33. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  34. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  35. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  36. data/lib/generators/support_desk/templates/console/controller.rb.erb +1 -1
  37. data/lib/generators/support_desk/templates/initializer.rb +63 -0
  38. data/lib/generators/support_desk/upgrade_generator.rb +66 -0
  39. data/lib/support_desk/assistant_policy.rb +213 -0
  40. data/lib/support_desk/brief.rb +283 -0
  41. data/lib/support_desk/configuration.rb +699 -3
  42. data/lib/support_desk/console.rb +634 -30
  43. data/lib/support_desk/console_engine.rb +2 -0
  44. data/lib/support_desk/console_routes.rb +20 -8
  45. data/lib/support_desk/context_card.rb +33 -1
  46. data/lib/support_desk/doctor.rb +195 -2
  47. data/lib/support_desk/engine.rb +10 -0
  48. data/lib/support_desk/errors.rb +42 -0
  49. data/lib/support_desk/events.rb +12 -5
  50. data/lib/support_desk/macros.rb +41 -12
  51. data/lib/support_desk/models/assignment.rb +3 -1
  52. data/lib/support_desk/models/assistant.rb +153 -0
  53. data/lib/support_desk/models/concerns/agent.rb +39 -4
  54. data/lib/support_desk/models/concerns/requester.rb +45 -12
  55. data/lib/support_desk/models/desk.rb +26 -3
  56. data/lib/support_desk/models/draft.rb +266 -0
  57. data/lib/support_desk/models/event.rb +15 -1
  58. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  59. data/lib/support_desk/models/ticket.rb +781 -120
  60. data/lib/support_desk/outcome.rb +38 -0
  61. data/lib/support_desk/queue.rb +41 -5
  62. data/lib/support_desk/summary.rb +1 -1
  63. data/lib/support_desk/test_helpers.rb +160 -3
  64. data/lib/support_desk/timeline.rb +40 -11
  65. data/lib/support_desk/topic.rb +22 -0
  66. data/lib/support_desk/topic_tree.rb +7 -1
  67. data/lib/support_desk/transcript.rb +237 -0
  68. data/lib/support_desk/version.rb +1 -1
  69. data/lib/support_desk/wizard.rb +4 -12
  70. data/lib/support_desk.rb +153 -0
  71. data/lib/tasks/support_desk.rake +73 -0
  72. metadata +35 -8
data/Rakefile CHANGED
@@ -21,6 +21,22 @@ end
21
21
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
22
22
  load "rails/tasks/engine.rake"
23
23
 
24
+ # `db:migrate:reset` — the command the README and CI give for the PostgreSQL
25
+ # leg — is `["db:drop", "db:create", "db:schema:dump", "db:migrate"]` in
26
+ # ActiveRecord's own databases.rake. That third step is hardwired: it ignores
27
+ # the dummy's `config.active_record.dump_schema_after_migration = false`,
28
+ # which is why plain `rake db:migrate` leaves no dump and this task does.
29
+ #
30
+ # The dump is stamped with the Rails version that wrote it
31
+ # (`ActiveRecord::Schema[8.1]`), and every later leg's `db:test:load_schema`
32
+ # prefers a schema.rb over the migrations — so one PostgreSQL run made the
33
+ # 7.2 Appraisal die with "Unknown migration version 8.1" until the file was
34
+ # deleted by hand. The dummy migrates from db/migrate and never from a dump,
35
+ # so the artifact has no reader: throw it away where it is made.
36
+ if Rake::Task.task_defined?("db:migrate:reset")
37
+ Rake::Task["db:migrate:reset"].enhance { rm_f "test/dummy/db/schema.rb" }
38
+ end
39
+
24
40
  require "rake/testtask"
25
41
 
26
42
  Rake::TestTask.new(:test) do |t|
@@ -206,6 +206,16 @@
206
206
  cursor: pointer;
207
207
  }
208
208
 
209
+ /* The way out of a machine. Quiet on purpose: it sits inside a live
210
+ conversation, where the loud button is the one that sends a message. */
211
+ .support-desk-button--quiet {
212
+ padding: 0.5rem 1rem;
213
+ border: 1px solid var(--support-desk-border);
214
+ background: transparent;
215
+ color: var(--support-desk-text);
216
+ font-weight: 600;
217
+ }
218
+
209
219
  /* --- Notices ---------------------------------------------------------------- */
210
220
 
211
221
  .support-desk-notice {
@@ -19,7 +19,10 @@ module SupportDesk
19
19
  # Their cases: open ones as rows, closed ones folded away, and the door
20
20
  # to open another.
21
21
  def index
22
- tickets = current_requester.support_tickets.includes(:desk, :subject, conversation: { last_message: :sender })
22
+ # `:assignee` because a row says who is on the case when that is a
23
+ # machine, and a name per row is a query per row.
24
+ tickets = current_requester.support_tickets
25
+ .includes(:desk, :subject, :assignee, conversation: { last_message: :sender })
23
26
  @open_tickets = tickets.not_closed.recent_activity_first.to_a
24
27
  @closed_tickets = tickets.closed.newest_first.limit(CLOSED_TICKETS_SHOWN).to_a
25
28
  @closed_count = tickets.closed.count
@@ -55,9 +58,11 @@ module SupportDesk
55
58
  # The form was submitted from a step that isn't the composer (a stale
56
59
  # tab, a hand-rolled POST): re-render the step they are actually on.
57
60
  render :new, status: :unprocessable_entity
58
- rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable
59
- # The subject passed the wizard's check and failed the model's. Same
60
- # answer as a forged token: 404.
61
+ rescue SupportDesk::NotAllowed, SupportDesk::NotSupportable, SupportDesk::NotARequester
62
+ # The subject passed the wizard's check and failed the model's, or this
63
+ # account stopped being able to write between the form and the submit.
64
+ # Same answer as a forged token: 404. "Not yours", "not there" and "not
65
+ # eligible" must look the same from outside.
61
66
  raise ActiveRecord::RecordNotFound
62
67
  end
63
68
 
@@ -68,6 +73,25 @@ module SupportDesk
68
73
  redirect_to conversation_path_for(find_ticket)
69
74
  end
70
75
 
76
+ # "Prefiero hablar con una persona." The one thing a requester can
77
+ # always do on their own case, and the reason an assistant is allowed to
78
+ # answer at all: nobody is ever trapped in a conversation with a machine.
79
+ #
80
+ # Scoped through their OWN cases, so a foreign id is a 404 and not a
81
+ # refusal that confirms the case exists. Idempotent by the model, so a
82
+ # second press is a second notice and nothing else.
83
+ def request_human
84
+ ticket = find_ticket
85
+ ticket.request_human!(by: current_requester, request: request)
86
+ redirect_to ticket_path(ticket), status: :see_other,
87
+ notice: t("support_desk.thread.human_requested_notice")
88
+ rescue SupportDesk::Locked, SupportDesk::InvalidTransition
89
+ # A closed case on a desk that locks them, or one the model won't
90
+ # reopen. The case is still theirs to read, so this is the door they
91
+ # came from with the reason on it — never a 500, and never silence.
92
+ redirect_to ticket_path(ticket), status: :see_other, alert: ticket.chat_locked_notice
93
+ end
94
+
71
95
  private
72
96
 
73
97
  def chat_rate_limit_messager = current_requester
@@ -54,6 +54,22 @@ module SupportDesk
54
54
  tag.span(count > 99 ? "99+" : count, class: css_class)
55
55
  end
56
56
 
57
+ # The door out of a machine, for a thread: "Prefiero hablar con una
58
+ # persona", the promise once somebody has asked, or nothing.
59
+ #
60
+ # <%= support_human_door(ticket) %>
61
+ #
62
+ # It is a helper rather than a partial hosts render by path because the
63
+ # thread is CHATS' screen, not this engine's: a host that has ejected
64
+ # chats' views drops this one line into their own and the door works,
65
+ # wherever their copy lives and whatever they renamed around it.
66
+ # Renders nothing when there is no case, so it is safe unconditionally.
67
+ def support_human_door(ticket)
68
+ return if ticket.nil?
69
+
70
+ render partial: "support_desk/tickets/human_door", locals: { ticket: ticket }
71
+ end
72
+
57
73
  # --- What the bundled views render with -----------------------------------
58
74
 
59
75
  # The gem's bundled stylesheet, into the host layout's <head> — where a
@@ -75,13 +91,19 @@ module SupportDesk
75
91
  within = SupportDesk.config.desk(desk_key).reply_within
76
92
  return nil if within.nil?
77
93
 
78
- t("support_desk.thread.promise", time: SupportDesk::Wizard.humanize_duration(within))
94
+ t("support_desk.thread.promise", time: SupportDesk.humanize_duration(within))
79
95
  end
80
96
 
81
97
  # The status line under a case in the requester's list: who owes the next
82
98
  # word, or that it's over.
83
99
  def support_ticket_state(ticket)
84
100
  return t("support_desk.tickets.state.closed") if ticket.closed?
101
+ # A conversation the desk started, before they have said anything back:
102
+ # "we replied" would be a lie about a thread they never opened, and
103
+ # "we're on it" is worse — there is nothing of theirs to be on.
104
+ if ticket.opened_by_support? && ticket.last_requester_message_at.nil?
105
+ return t("support_desk.tickets.state.opened_by_support")
106
+ end
85
107
 
86
108
  ticket.awaiting_reply? ? t("support_desk.tickets.state.awaiting_reply") : t("support_desk.tickets.state.answered")
87
109
  end
@@ -22,6 +22,21 @@
22
22
  class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
23
23
  <% end %>
24
24
 
25
+ <%# Pausing is not a policy change: it switches her off on THIS case and
26
+ throws away whatever she had proposed for it. Only one of the two ever
27
+ shows, because `actions_for` offers only one. %>
28
+ <% if actions.include?(:pause_assistant) %>
29
+ <%= button_to t("support_desk.console.actions.pause_assistant"),
30
+ console_ticket_path(ticket, :pause_assistant), method: :post,
31
+ class: "rounded-md border border-slate-300 px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
32
+ <% end %>
33
+
34
+ <% if actions.include?(:resume_assistant) %>
35
+ <%= button_to t("support_desk.console.actions.resume_assistant"),
36
+ console_ticket_path(ticket, :resume_assistant), method: :post,
37
+ class: "rounded-md border border-indigo-300 px-3 py-1.5 text-sm font-medium text-indigo-800 hover:bg-indigo-50" %>
38
+ <% end %>
39
+
25
40
  <% if actions.include?(:reopen) %>
26
41
  <%= button_to t("support_desk.console.actions.reopen"), console_ticket_path(ticket, :reopen), method: :post,
27
42
  class: "rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-700" %>
@@ -14,7 +14,18 @@
14
14
  <h2 class="text-sm font-semibold"><%= t("support_desk.console.assignment.title") %></h2>
15
15
 
16
16
  <p class="mt-2 text-sm text-slate-600">
17
- <% if ticket.assigned? %>
17
+ <% if ticket.held_by_assistant? %>
18
+ <%# A machine holding a case is not the same sentence as a colleague
19
+ holding it: what matters is how much rope she has left, because
20
+ that is when a person has to be here. %>
21
+ <% assistant = ticket.assignee %>
22
+ <% if assistant.max_turns %>
23
+ <%= t("support_desk.console.badges.assistant_holds", name: assistant.support_agent_name,
24
+ used: ticket.assistant_turns_count.to_i, max: assistant.max_turns) %>
25
+ <% else %>
26
+ <%= t("support_desk.console.badges.assistant_holds_unlimited", name: assistant.support_agent_name) %>
27
+ <% end %>
28
+ <% elsif ticket.assigned? %>
18
29
  <%= t("support_desk.console.assignment.held_by", agent: ticket.assignee.support_agent_name) %>
19
30
  <% else %>
20
31
  <%= t("support_desk.console.assignment.unassigned") %>
@@ -36,8 +47,11 @@
36
47
  <% if agents.any? && (actions.include?(:hand_off) || actions.include?(:assign)) %>
37
48
  <% handing_off = actions.include?(:hand_off) %>
38
49
  <%= form_with url: console_ticket_path(ticket, handing_off ? :hand_off : :assign), method: :post, class: "space-y-2" do %>
50
+ <%# Values are actor KEYS, not ids: the pool is people and a machine,
51
+ drawn from different tables, and a bare "1" could name either. %>
39
52
  <%= select_tag :agent_id,
40
- options_from_collection_for_select(agents, :id, :support_agent_name),
53
+ options_from_collection_for_select(agents, ->(agent) { SupportDesk.actor_key(agent) },
54
+ :support_agent_name),
41
55
  include_blank: t("support_desk.console.assignment.choose_agent"),
42
56
  required: true,
43
57
  class: "w-full rounded-md border border-slate-300 p-2 text-sm" %>
@@ -8,6 +8,14 @@
8
8
  composer that still works on the day the bundle fails to build. %>
9
9
  <% composing_note = params[:compose].to_s == "note" %>
10
10
  <% may_reply = actions.include?(:reply) %>
11
+ <%# "Editar" on a proposal opens THIS box with the proposal in it, at
12
+ `?compose=reply&draft=ID`. The concern decides whether that is a real
13
+ edit (the draft is still the pending one, and this agent is offered the
14
+ send) — a view never does. It is also where a stale send lands, with
15
+ whatever the reviewer had typed still in the box and the case's CURRENT
16
+ turn in the hidden field, because a redirect would have thrown their
17
+ words away. %>
18
+ <% editing = composing_note ? nil : support_editing_draft(ticket) %>
11
19
 
12
20
  <section class="rounded-lg border border-slate-200 bg-white">
13
21
  <nav class="flex gap-1 border-b border-slate-200 px-2 pt-2">
@@ -33,6 +41,25 @@
33
41
  </div>
34
42
  <% end %>
35
43
 
44
+ <% elsif editing %>
45
+ <% assistant_name = editing.author.try(:support_agent_name) || editing.author.to_s %>
46
+ <p class="mb-2 text-xs font-medium text-indigo-800">
47
+ <%= t("support_desk.console.draft.editing", name: assistant_name) %>
48
+ </p>
49
+
50
+ <%= form_with url: console_ticket_path(ticket, :send_draft), method: :post do %>
51
+ <%= hidden_field_tag :draft_id, editing.id %>
52
+ <%= hidden_field_tag :seen_turn, support_seen_turn(ticket) %>
53
+ <%= text_area_tag :body, (params[:body].is_a?(String) && params[:body].present? ? params[:body] : editing.body),
54
+ rows: 5, class: "w-full rounded-md border border-indigo-300 p-2 text-sm" %>
55
+ <div class="mt-2 flex flex-wrap items-center justify-between gap-3">
56
+ <%= link_to t("support_desk.console.draft.cancel_edit"), console_ticket_path(ticket),
57
+ class: "text-xs text-slate-500 underline underline-offset-2 hover:text-slate-900" %>
58
+ <%= submit_tag t("support_desk.console.actions.send_edited_draft"),
59
+ class: "rounded-md bg-indigo-700 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-800" %>
60
+ </div>
61
+ <% end %>
62
+
36
63
  <% elsif may_reply %>
37
64
  <%# multipart is explicit because `form_with` only infers it from the
38
65
  FORM BUILDER's file_field, and this is a file_field_tag — without
@@ -26,6 +26,11 @@
26
26
  </div>
27
27
  <% end %>
28
28
 
29
+ <div class="flex justify-between gap-3">
30
+ <dt class="text-slate-500"><%= t("support_desk.console.context.opened_by") %></dt>
31
+ <dd class="text-right"><%= card.opened_by_label %></dd>
32
+ </div>
33
+
29
34
  <div class="flex justify-between gap-3">
30
35
  <dt class="text-slate-500"><%= t("support_desk.console.context.topic") %></dt>
31
36
  <dd class="text-right"><%= card.topic_label %></dd>
@@ -44,8 +49,36 @@
44
49
  <dd class="text-right"><%= value %></dd>
45
50
  </div>
46
51
  <% end %>
52
+
53
+ <%# What she may do here, and the rule that decided it. The LEVEL is the
54
+ answer an agent needs ("she can only propose on this one"); the
55
+ `because` is written for whoever is reading a stack trace, so it
56
+ rides in the tooltip rather than on the card. %>
57
+ <% if (assistant = card.ticket.assistant) %>
58
+ <% policy = card.ticket.assistant_policy(assistant) %>
59
+ <div class="flex justify-between gap-3">
60
+ <dt class="text-slate-500"><%= t("support_desk.console.context.assistant") %></dt>
61
+ <dd class="text-right font-medium" title="<%= policy.because %>"><%= policy.level %></dd>
62
+ </div>
63
+ <% end %>
47
64
  </dl>
48
65
 
66
+ <%# The host's own words about the person who is asking — the same
67
+ `support_context` a brief hands the machine, on the screen for the
68
+ person doing the same job. %>
69
+ <% requester_context = card.requester.try(:support_context) || {} %>
70
+ <% if requester_context.respond_to?(:each_pair) && requester_context.any? %>
71
+ <h3 class="mt-4 text-sm font-semibold text-slate-900"><%= t("support_desk.console.context.requester_context") %></h3>
72
+ <dl class="mt-2 space-y-2 text-sm">
73
+ <% requester_context.each_pair do |key, value| %>
74
+ <div class="flex justify-between gap-3">
75
+ <dt class="text-slate-500"><%= key.to_s.humanize %></dt>
76
+ <dd class="text-right"><%= value %></dd>
77
+ </div>
78
+ <% end %>
79
+ </dl>
80
+ <% end %>
81
+
49
82
  <% if card.subject_url.present? %>
50
83
  <%= link_to t("support_desk.console.context.open_subject"), card.subject_url,
51
84
  class: "mt-3 inline-block text-sm font-medium text-slate-900 underline underline-offset-2" %>
@@ -0,0 +1,114 @@
1
+ <%# The assistant's proposal, and the two decisions a person makes about it.
2
+
3
+ The card sits ABOVE the composer on purpose: it is the first thing on
4
+ the screen that wants an answer, and the composer underneath is where
5
+ the same person writes their own words when this one is wrong.
6
+
7
+ Nothing here is sent by a machine. "Enviar" posts a reply FROM THE
8
+ AGENT — their signature, their case, their responsibility — carrying
9
+ the turn this page was rendered with, so a proposal approved from a page
10
+ that predates the customer's next message comes back as a warning
11
+ instead of landing in a conversation that moved on.
12
+
13
+ Buttons come from `actions`, like everywhere else in this console: a
14
+ desk where this agent may not answer shows the proposal and offers only
15
+ "Descartar", which is exactly what the server would accept. %>
16
+ <%# The author by the name the requester is reading (`support_agent_name` is
17
+ her disclosed name): whoever sends this is about to own what it says, so
18
+ the card names her the way the conversation does. %>
19
+ <% assistant_name = draft.author.try(:support_agent_name) || draft.author.to_s %>
20
+
21
+ <section class="rounded-lg border border-indigo-200 bg-indigo-50 p-4" id="<%= dom_id(draft) %>">
22
+ <header class="flex flex-wrap items-center justify-between gap-2">
23
+ <h2 class="text-sm font-semibold text-indigo-900">
24
+ <span aria-hidden="true">🤖</span>
25
+ <%= t("support_desk.console.draft.title", name: assistant_name) %>
26
+ </h2>
27
+
28
+ <% if draft.confidence_percent %>
29
+ <span class="rounded-full bg-white px-2 py-0.5 text-xs font-medium text-indigo-700">
30
+ <%= t("support_desk.console.draft.confidence", percent: draft.confidence_percent) %>
31
+ </span>
32
+ <% end %>
33
+ </header>
34
+
35
+ <%# Escaped text with pre-wrap, never simple_format: what a model wrote is
36
+ literal text, and it is about to be sent as somebody's own words. %>
37
+ <div class="mt-3 whitespace-pre-wrap break-words rounded-md bg-white p-3 text-sm text-slate-900"><%= draft.body %></div>
38
+
39
+ <% sources = Array(draft.sources).select { |source| source.is_a?(Hash) && source["title"].present? } %>
40
+ <% if sources.any? %>
41
+ <div class="mt-3 text-xs">
42
+ <p class="font-semibold text-indigo-900"><%= t("support_desk.console.draft.sources") %></p>
43
+ <ul class="mt-1 space-y-0.5">
44
+ <% sources.each do |source| %>
45
+ <li>
46
+ <%# http(s) only, and checked HERE as well as on the model: a
47
+ `javascript:` url in a citation is a link an agent clicks. %>
48
+ <% if source["url"].to_s.match?(%r{\Ahttps?://}i) %>
49
+ <%= link_to source["title"], source["url"], target: "_blank", rel: "noopener",
50
+ class: "text-indigo-700 underline underline-offset-2" %>
51
+ <% else %>
52
+ <span class="text-slate-600"><%= source["title"] %></span>
53
+ <% end %>
54
+ </li>
55
+ <% end %>
56
+ </ul>
57
+ </div>
58
+ <% end %>
59
+
60
+ <% if draft.files_attached? %>
61
+ <div class="mt-3 text-xs">
62
+ <p class="font-semibold text-indigo-900"><%= t("support_desk.console.draft.attachments") %></p>
63
+ <ul class="mt-1 space-y-0.5">
64
+ <% draft.files.each do |file| %>
65
+ <li>
66
+ <%= link_to file.filename.to_s, console_file_path(file), target: "_blank", rel: "noopener",
67
+ class: "text-indigo-700 underline underline-offset-2" %>
68
+ </li>
69
+ <% end %>
70
+ </ul>
71
+ </div>
72
+ <% end %>
73
+
74
+ <% if draft.stale? %>
75
+ <p class="mt-3 rounded-md bg-amber-100 p-2 text-xs font-medium text-amber-900">
76
+ <%= t("support_desk.console.draft.stale") %>
77
+ </p>
78
+ <% end %>
79
+
80
+ <div class="mt-3 flex flex-wrap items-center gap-2">
81
+ <% if actions.include?(:send_draft) %>
82
+ <%# `id: nil` on every hidden field in this card: the composer below
83
+ carries the same two names in its edit mode, and two elements with
84
+ one id is a form field that `fill_in` and a label can both reach
85
+ the wrong one of. The NAME is what a request is made of. %>
86
+ <%= form_with url: console_ticket_path(ticket, :send_draft), method: :post, class: "inline" do %>
87
+ <%= hidden_field_tag :draft_id, draft.id, id: nil %>
88
+ <%# The turn THIS page was rendered with. Not the one the form
89
+ submits at: the whole point is to compare them. %>
90
+ <%= hidden_field_tag :seen_turn, support_seen_turn(ticket), id: nil %>
91
+ <%= submit_tag t("support_desk.console.actions.send_draft"),
92
+ class: "rounded-md bg-indigo-700 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-800" %>
93
+ <% end %>
94
+
95
+ <%= link_to t("support_desk.console.actions.edit_draft"),
96
+ console_ticket_path(ticket, :show, compose: :reply, draft: draft.id),
97
+ class: "rounded-md border border-indigo-300 px-3 py-1.5 text-sm font-medium text-indigo-800 hover:bg-white" %>
98
+ <% end %>
99
+
100
+ <% if actions.include?(:reject_draft) %>
101
+ <%= form_with url: console_ticket_path(ticket, :reject_draft), method: :post,
102
+ class: "flex flex-wrap items-center gap-2" do %>
103
+ <%# WHICH proposal is being discarded, in the form: the action looks
104
+ the row up by id in any status, so a discard with nothing to name
105
+ is refused before the model is asked. %>
106
+ <%= hidden_field_tag :draft_id, draft.id, id: nil %>
107
+ <%= text_field_tag :reason, nil, placeholder: t("support_desk.console.draft.reason_placeholder"),
108
+ class: "rounded-md border border-indigo-200 px-2 py-1.5 text-sm" %>
109
+ <%= submit_tag t("support_desk.console.actions.reject_draft"),
110
+ class: "rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-slate-50" %>
111
+ <% end %>
112
+ <% end %>
113
+ </div>
114
+ </section>
@@ -8,6 +8,21 @@
8
8
  from an agent who isn't the assignee is a drop-in: the timeline records
9
9
  it, and the signature is what makes it visible here. %>
10
10
  <% from_requester = message.sender_type == ticket.requester_type && message.sender_id.to_s == ticket.requester_id.to_s %>
11
+ <%# Two marks, and they mean opposite things. The robot says a MACHINE
12
+ wrote this and it went out as it stood — what the requester saw depends
13
+ on the disclosure mode, but the desk always knows. The "propuesta de"
14
+ line says a machine wrote it and a PERSON read it and sent it: that
15
+ message is theirs, and the mark is provenance, not authorship.
16
+
17
+ Both are staff-only: this partial renders the console, never the thread.
18
+ The provenance stamp is read from the message's own metadata so a host
19
+ that changes disclosure mode tomorrow doesn't rewrite yesterday's
20
+ bubbles. %>
21
+ <% stamp = message.try(:metadata) %>
22
+ <% stamp = stamp.is_a?(Hash) ? stamp["support_desk"] : nil %>
23
+ <% stamp = stamp.is_a?(Hash) ? stamp : {} %>
24
+ <% from_assistant = ticket.assistant_message?(message) %>
25
+ <% drafted_by = stamp["drafted_by"] %>
11
26
 
12
27
  <% if message.system? %>
13
28
  <p class="text-center text-xs text-slate-400"><%= message.visible_body %></p>
@@ -37,7 +52,25 @@
37
52
  <% end %>
38
53
  <% end %>
39
54
 
40
- <div class="mt-1 flex items-center gap-2 text-[11px] <%= from_requester ? "text-slate-500" : "text-white/60" %>">
55
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] <%= from_requester ? "text-slate-500" : "text-white/60" %>">
56
+ <% if from_assistant %>
57
+ <%# The declared confidence rides in the tooltip rather than on
58
+ the bubble: it is a number a model made up about itself, and
59
+ it belongs where somebody looking for it can find it. %>
60
+ <% confidence = stamp["confidence"] %>
61
+ <%= tag.span("🤖", "aria-hidden": true,
62
+ title: (t("support_desk.console.draft.confidence", percent: (confidence.to_f * 100).round) if confidence)) %>
63
+ <% end %>
64
+
65
+ <% if drafted_by.present? %>
66
+ <%# The name of whoever proposed it. The desk's own assistant
67
+ answers without a query; an assistant a host has since
68
+ replaced falls back to the desk, because a lookup per bubble
69
+ is a page that gets slower the more the machine helped. %>
70
+ <% proposer = ticket.assistant if ticket.assistant && SupportDesk.actor_key(ticket.assistant) == drafted_by %>
71
+ <span><%= t("support_desk.console.draft.from", name: proposer&.support_agent_name || ticket.desk.name) %></span>
72
+ <% end %>
73
+
41
74
  <% if (signature = chats_message_signature(message)) %>
42
75
  <span><%= signature %></span>
43
76
  <% end %>
@@ -0,0 +1,91 @@
1
+ <%# The compose form. Everything it renders is a draft the controller set
2
+ BEFORE it looked anything up, so a refusal comes back with the message
3
+ still in the box.
4
+
5
+ Three shapes for "who is this for", in order of how sure we are:
6
+
7
+ @requester a card — the page that opened this form knew
8
+ find_requester a text field — the host said how to look people up
9
+ neither a hint naming the setting, and no send button,
10
+ because a form that can't reach anybody is worse
11
+ than one that says so
12
+
13
+ Deliberately NOT promised here: who ends up holding the case. If this
14
+ person already has this conversation open, the message joins it as an
15
+ ordinary reply and the desk's reply policy decides the seat. %>
16
+ <%= form_with url: console_tickets_path(:open_conversation, desk: support_desk_record.key),
17
+ method: :post, html: { multipart: true }, class: "space-y-4" do %>
18
+ <section class="rounded-lg border border-slate-200 bg-white p-4">
19
+ <h2 class="text-sm font-semibold"><%= t("support_desk.console.new_conversation.requester") %></h2>
20
+
21
+ <% if @requester %>
22
+ <div class="mt-2 flex items-center justify-between gap-3">
23
+ <p class="text-sm font-medium"><%= Chats.display_name_for(@requester) %></p>
24
+ <%= hidden_field_tag :requester, @requester.to_global_id.to_s %>
25
+ </div>
26
+ <% elsif support_desk_record.config.find_requester %>
27
+ <%# Text, not an email field: the host's callback may look people up by
28
+ phone, handle or anything else they chose. %>
29
+ <%= text_field_tag :requester_query, @requester_query,
30
+ placeholder: t("support_desk.console.new_conversation.requester_query"),
31
+ class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
32
+ <% else %>
33
+ <p class="mt-2 text-sm text-slate-500"><%= t("support_desk.console.new_conversation.requester_hint") %></p>
34
+ <% end %>
35
+
36
+ <% if @about %>
37
+ <p class="mt-3 text-sm text-slate-500">
38
+ <%= t("support_desk.console.new_conversation.about", subject: @about.support_label) %>
39
+ </p>
40
+ <%= hidden_field_tag :about, @about.to_global_id.to_s %>
41
+ <% end %>
42
+ </section>
43
+
44
+ <section class="rounded-lg border border-slate-200 bg-white p-4">
45
+ <%# A subject brings its own topic with it, so the picker is only for
46
+ cases that are about nothing in particular. The supplied topic is
47
+ preserved either way. %>
48
+ <% if @about %>
49
+ <%= hidden_field_tag :topic, @topic %>
50
+ <% else %>
51
+ <label class="text-sm font-semibold" for="topic"><%= t("support_desk.console.new_conversation.topic") %></label>
52
+ <%= select_tag :topic,
53
+ options_for_select(support_conversation_topics.map { |topic| [ topic.full_label, topic.path ] }, @topic),
54
+ class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
55
+ <% end %>
56
+
57
+ <label class="mt-4 block text-sm font-semibold" for="body">
58
+ <%= t("support_desk.console.new_conversation.message") %>
59
+ </label>
60
+ <%= text_area_tag :body, @body, rows: 5, maxlength: Chats.config.max_message_length,
61
+ class: "mt-2 w-full rounded-md border border-slate-300 p-2 text-sm" %>
62
+
63
+ <p class="mt-2 text-xs text-slate-500">
64
+ <%= t("support_desk.console.new_conversation.signed_as", agent: current_agent.support_agent_name) %>
65
+ </p>
66
+
67
+ <% if Chats.config.attachments %>
68
+ <div class="mt-3">
69
+ <%= file_field_tag "files[]", multiple: true, class: "text-xs text-slate-500" %>
70
+ <%# Browsers can't be handed a file input back. Say so rather than
71
+ letting somebody assume their attachment survived the 422. %>
72
+ <p class="mt-1 text-xs text-slate-500"><%= t("support_desk.console.errors.attachments_again") %></p>
73
+ </div>
74
+ <% end %>
75
+ </section>
76
+
77
+ <div class="flex items-center justify-between gap-3">
78
+ <%= link_to t("support_desk.console.new_conversation.cancel"), console_tickets_path,
79
+ class: "text-sm text-slate-500 underline underline-offset-2" %>
80
+
81
+ <% if support_conversation_sendable? && (@requester || support_desk_record.config.find_requester) %>
82
+ <%= submit_tag t("support_desk.console.new_conversation.send", desk: support_desk_record.name),
83
+ class: "rounded-md bg-slate-900 px-3 py-2 text-sm font-medium text-white hover:bg-slate-700" %>
84
+ <% elsif flash[:alert].blank? %>
85
+ <p class="text-sm text-slate-500">
86
+ <%= support_conversation_available? ? t("support_desk.console.new_conversation.requester_hint") :
87
+ t("support_desk.console.errors.off_duty") %>
88
+ </p>
89
+ <% end %>
90
+ </div>
91
+ <% end %>
@@ -20,6 +20,31 @@
20
20
  </span>
21
21
 
22
22
  <span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
23
+
24
+ <%# The two rows an agent must not scroll past. "Necesita persona"
25
+ is a case a machine could not finish; "Propuesta lista" is one
26
+ where the work is already written and needs reading. Both are
27
+ free here — the index preloads `pending_draft`, and the flag is
28
+ a column. %>
29
+ <% if ticket.human_required? %>
30
+ <span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900">
31
+ <%= t("support_desk.console.badges.needs_human") %>
32
+ </span>
33
+ <% end %>
34
+
35
+ <% if ticket.pending_draft %>
36
+ <span class="rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-800">
37
+ <%= t("support_desk.console.badges.pending_draft") %>
38
+ </span>
39
+ <% end %>
40
+
41
+ <%# A case we started reads differently from one somebody asked for:
42
+ nobody is waiting on us in it until they answer. %>
43
+ <% if ticket.opened_by_support? %>
44
+ <span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
45
+ <%= t("support_desk.console.row.opened_by_support") %>
46
+ </span>
47
+ <% end %>
23
48
  </div>
24
49
 
25
50
  <p class="mt-0.5 text-sm text-slate-500">
@@ -46,6 +71,9 @@
46
71
  </span>
47
72
  <% end %>
48
73
 
74
+ <%# The holder, by the name the REQUESTER is reading: an assistant
75
+ answers `support_agent_name` with her disclosed name, so a desk
76
+ never has to look up what the customer thinks they are talking to. %>
49
77
  <span class="text-slate-500">
50
78
  <%= ticket.assignee&.support_agent_name || t("support_desk.console.row.unassigned") %>
51
79
  </span>