support_desk 0.2.0 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +998 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +12 -0
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  30. data/lib/generators/support_desk/upgrade_generator.rb +12 -2
  31. data/lib/support_desk/assistant_policy.rb +213 -0
  32. data/lib/support_desk/brief.rb +283 -0
  33. data/lib/support_desk/configuration.rb +552 -2
  34. data/lib/support_desk/console.rb +290 -8
  35. data/lib/support_desk/context_card.rb +10 -1
  36. data/lib/support_desk/doctor.rb +144 -1
  37. data/lib/support_desk/engine.rb +10 -0
  38. data/lib/support_desk/errors.rb +37 -0
  39. data/lib/support_desk/events.rb +12 -5
  40. data/lib/support_desk/macros.rb +14 -1
  41. data/lib/support_desk/models/assistant.rb +153 -0
  42. data/lib/support_desk/models/concerns/requester.rb +18 -0
  43. data/lib/support_desk/models/desk.rb +26 -3
  44. data/lib/support_desk/models/draft.rb +266 -0
  45. data/lib/support_desk/models/event.rb +15 -1
  46. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  47. data/lib/support_desk/models/ticket.rb +334 -37
  48. data/lib/support_desk/outcome.rb +38 -0
  49. data/lib/support_desk/queue.rb +41 -5
  50. data/lib/support_desk/test_helpers.rb +152 -0
  51. data/lib/support_desk/timeline.rb +40 -11
  52. data/lib/support_desk/topic.rb +22 -0
  53. data/lib/support_desk/topic_tree.rb +7 -1
  54. data/lib/support_desk/transcript.rb +237 -0
  55. data/lib/support_desk/version.rb +1 -1
  56. data/lib/support_desk.rb +108 -0
  57. data/lib/tasks/support_desk.rake +46 -0
  58. metadata +30 -8
@@ -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
@@ -70,6 +73,25 @@ module SupportDesk
70
73
  redirect_to conversation_path_for(find_ticket)
71
74
  end
72
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
+
73
95
  private
74
96
 
75
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
@@ -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
@@ -49,8 +49,36 @@
49
49
  <dd class="text-right"><%= value %></dd>
50
50
  </div>
51
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 %>
52
64
  </dl>
53
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
+
54
82
  <% if card.subject_url.present? %>
55
83
  <%= link_to t("support_desk.console.context.open_subject"), card.subject_url,
56
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 %>
@@ -21,6 +21,23 @@
21
21
 
22
22
  <span class="font-mono text-xs text-slate-400"><%= ticket.reference %></span>
23
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
+
24
41
  <%# A case we started reads differently from one somebody asked for:
25
42
  nobody is waiting on us in it until they answer. %>
26
43
  <% if ticket.opened_by_support? %>
@@ -54,6 +71,9 @@
54
71
  </span>
55
72
  <% end %>
56
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. %>
57
77
  <span class="text-slate-500">
58
78
  <%= ticket.assignee&.support_agent_name || t("support_desk.console.row.unassigned") %>
59
79
  </span>
@@ -4,7 +4,12 @@
4
4
 
5
5
  Collapsed by default, because an agent opens a case to answer it, not to
6
6
  audit it. `<details>` rather than a toggle controller: the browser has
7
- had this for a decade and it survives a broken bundle. %>
7
+ had this for a decade and it survives a broken bundle.
8
+
9
+ Three kinds of moment now. A message and an event read as they always
10
+ did. A DRAFT reads greyed, because it is the one thing here that was
11
+ never said to anybody: a proposal somebody discarded, replaced, or sent
12
+ in somebody else's words. %>
8
13
  <details class="rounded-lg border border-slate-200 bg-white p-4">
9
14
  <summary class="cursor-pointer text-sm font-semibold"><%= t("support_desk.console.timeline.title") %></summary>
10
15
 
@@ -15,22 +20,71 @@
15
20
  <%= l(entry.at, format: :short) if entry.at %>
16
21
  </time>
17
22
 
18
- <div class="min-w-0">
19
- <span class="font-medium text-slate-700">
20
- <%= t("support_desk.console.timeline.kinds.#{entry.kind}", default: entry.kind.to_s.humanize) %>
21
- </span>
23
+ <% actor = entry.actor %>
24
+ <% actor_name = if actor.nil?
25
+ nil
26
+ elsif actor.is_a?(Symbol)
27
+ t("support_desk.console.timeline.system")
28
+ else
29
+ actor.try(:support_agent_name) || Chats.display_name_for(actor)
30
+ end %>
22
31
 
23
- <% actor = entry.actor %>
24
- <% if actor %>
25
- <span class="text-slate-500">
26
- · <%= actor.is_a?(Symbol) ? t("support_desk.console.timeline.system") : (actor.try(:support_agent_name) || Chats.display_name_for(actor)) %>
32
+ <% if entry.draft? %>
33
+ <%# What the machine proposed, and what became of it. The event
34
+ beside it says who decided; this is what they decided ABOUT. %>
35
+ <% draft = entry.draft %>
36
+ <div class="min-w-0 text-slate-400">
37
+ <span class="font-medium">
38
+ <% case draft.status %>
39
+ <% when "sent" %>
40
+ <%= t("support_desk.console.draft.sent_by", name: actor_name) %>
41
+ <% when "rejected" %>
42
+ <%= t("support_desk.console.draft.rejected_by", name: actor_name) %>
43
+ <% when "superseded" %>
44
+ <%= t("support_desk.console.timeline.draft_superseded") %>
45
+ <% else %>
46
+ <%= t("support_desk.console.timeline.draft_expired") %>
47
+ <% end %>
27
48
  </span>
28
- <% end %>
49
+ <% if entry.body.present? %>
50
+ <p class="truncate"><%= truncate(entry.body, length: 120) %></p>
51
+ <% end %>
52
+ <% if draft.rejection_reason.present? %>
53
+ <p class="truncate italic"><%= truncate(draft.rejection_reason, length: 120) %></p>
54
+ <% end %>
55
+ </div>
56
+
57
+ <% elsif (sentence = support_console_timeline_sentence(entry, actor_name)) %>
58
+ <%# The kinds the assistants added say the whole thing in one
59
+ sentence, with their own payload in it: "who did what to whom"
60
+ is the only reading of these that helps. %>
61
+ <div class="min-w-0">
62
+ <span class="text-slate-700"><%= sentence %></span>
63
+ <% if entry.kind == :escalated && entry.event.summary.present? %>
64
+ <%# Her hand-over note: the one piece of a machine's reasoning
65
+ a person actually needs, so it gets room rather than a
66
+ truncated line. %>
67
+ <p class="mt-1 whitespace-pre-wrap rounded-md bg-amber-50 p-2 text-xs text-amber-900">
68
+ <%= entry.event.summary %>
69
+ </p>
70
+ <% end %>
71
+ </div>
72
+
73
+ <% else %>
74
+ <div class="min-w-0">
75
+ <span class="font-medium text-slate-700">
76
+ <%= t("support_desk.console.timeline.kinds.#{entry.kind}", default: entry.kind.to_s.humanize) %>
77
+ </span>
78
+
79
+ <% if actor_name %>
80
+ <span class="text-slate-500">· <%= actor_name %></span>
81
+ <% end %>
29
82
 
30
- <% if entry.body.present? %>
31
- <p class="truncate text-slate-600"><%= truncate(entry.body, length: 120) %></p>
32
- <% end %>
33
- </div>
83
+ <% if entry.body.present? %>
84
+ <p class="truncate text-slate-600"><%= truncate(entry.body, length: 120) %></p>
85
+ <% end %>
86
+ </div>
87
+ <% end %>
34
88
  </li>
35
89
  <% end %>
36
90
  </ol>
@@ -33,6 +33,11 @@
33
33
  <div class="grid gap-6 lg:grid-cols-3">
34
34
  <div class="space-y-4 lg:col-span-2">
35
35
  <%= render "transcript", ticket: @ticket %>
36
+ <%# The proposal, when there is one waiting: above the composer,
37
+ because it is the thing on this screen that wants an answer. %>
38
+ <% if (draft = support_pending_draft) %>
39
+ <%= render "draft", ticket: @ticket, draft: draft, actions: @actions %>
40
+ <% end %>
36
41
  <%= render "composer", ticket: @ticket, actions: @actions %>
37
42
  </div>
38
43
 
@@ -0,0 +1,24 @@
1
+ <%# "Prefiero hablar con una persona."
2
+
3
+ The one control on this screen that is never taken away while a machine
4
+ is in play: a customer who wants a person gets a person, and pressing
5
+ this is the whole of it — no wording to get right, no phrase to guess.
6
+
7
+ Three states, and the middle one is the point. While the assistant is in
8
+ play the button is here. Once somebody has asked (the customer, the
9
+ assistant, or the sweep that notices she went quiet) the button becomes
10
+ the promise, because a door that stays pressable after it worked is a
11
+ door people press again wondering whether it did. A case nobody can
12
+ write to shows nothing at all: it is over, and a button that only 500s
13
+ is worse than silence.
14
+
15
+ `ticket.assistant_in_play?` counts HISTORY as well as configuration, so
16
+ the door does not vanish from a live conversation because somebody
17
+ edited an initializer. %>
18
+ <% if ticket.human_required? %>
19
+ <p class="support-desk-notice__title"><%= t("support_desk.thread.handled_by_humans") %></p>
20
+ <% elsif ticket.assistant_in_play? && !ticket.chat_locked? %>
21
+ <%= button_to t("support_desk.thread.human_door"),
22
+ support_desk_routes.request_human_ticket_path(ticket),
23
+ method: :post, class: "support-desk-button support-desk-button--quiet" %>
24
+ <% end %>
@@ -18,6 +18,19 @@
18
18
 
19
19
  <span class="chats-row__subject"><%= support_ticket_state(ticket) %></span>
20
20
 
21
+ <%# Who is on it, when that is not simply "us". A machine answering is
22
+ something the person is entitled to know before they read the
23
+ answer, so the row says so by her disclosed name — and the moment
24
+ a person has been asked for, it says THAT instead, because it is
25
+ the only one of the two that is a promise. %>
26
+ <% if ticket.human_required? %>
27
+ <span class="chats-row__subject"><%= t("support_desk.thread.handled_by_humans") %></span>
28
+ <% elsif ticket.held_by_assistant? %>
29
+ <span class="chats-row__subject">
30
+ <%= t("support_desk.thread.handled_by_assistant", name: ticket.assignee.support_agent_name) %>
31
+ </span>
32
+ <% end %>
33
+
21
34
  <span class="chats-row__bottom">
22
35
  <span class="chats-row__preview">
23
36
  <%= ticket.conversation ? chats_preview_for(ticket.conversation, current_requester) : "" %>