collavre 0.13.0 → 0.14.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +16 -2
  3. data/app/assets/stylesheets/collavre/design_tokens.css +1 -0
  4. data/app/assets/stylesheets/collavre/popup.css +6 -0
  5. data/app/components/collavre/inbox/badge_component.rb +1 -2
  6. data/app/controllers/collavre/comment_read_pointers_controller.rb +1 -1
  7. data/app/controllers/collavre/comments/reactions_controller.rb +2 -16
  8. data/app/controllers/collavre/comments/versions_controller.rb +2 -15
  9. data/app/controllers/collavre/comments_controller.rb +5 -17
  10. data/app/controllers/collavre/creatives_controller.rb +3 -3
  11. data/app/controllers/collavre/tasks_controller.rb +1 -25
  12. data/app/controllers/collavre/topics_controller.rb +7 -3
  13. data/app/controllers/concerns/collavre/comments/batch_operations.rb +14 -8
  14. data/app/controllers/concerns/collavre/comments/comment_scoping.rb +20 -0
  15. data/app/controllers/concerns/collavre/integration_permission.rb +31 -0
  16. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +1 -1
  17. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +2 -0
  18. data/app/helpers/collavre/application_helper.rb +2 -3
  19. data/app/helpers/collavre/creatives_helper.rb +1 -9
  20. data/app/helpers/collavre/navigation_helper.rb +0 -6
  21. data/app/javascript/controllers/agent_trigger_controller.js +94 -0
  22. data/app/javascript/controllers/comment_controller.js +2 -2
  23. data/app/javascript/controllers/comments/form_controller.js +80 -0
  24. data/app/javascript/controllers/comments/list_controller.js +58 -22
  25. data/app/javascript/controllers/comments/popup_controller.js +7 -0
  26. data/app/javascript/controllers/comments/topics_controller.js +16 -4
  27. data/app/javascript/controllers/index.js +3 -0
  28. data/app/models/collavre/comment/broadcastable.rb +1 -1
  29. data/app/models/collavre/comment.rb +8 -1
  30. data/app/models/collavre/creative/permissible.rb +23 -0
  31. data/app/models/collavre/creative.rb +0 -6
  32. data/app/models/collavre/inbox_item.rb +0 -2
  33. data/app/models/collavre/topic.rb +2 -0
  34. data/app/services/collavre/ai_agent_service.rb +5 -16
  35. data/app/services/collavre/comment_move_service.rb +1 -4
  36. data/app/services/collavre/inbox_reply_service.rb +87 -0
  37. data/app/services/collavre/openclaw_abort_service.rb +45 -0
  38. data/app/services/collavre/orchestration/agent_context_builder.rb +5 -3
  39. data/app/services/collavre/orchestration/stuck_detector.rb +7 -11
  40. data/app/services/collavre/topic_branch_service.rb +112 -0
  41. data/app/views/collavre/comments/_comments_popup.html.erb +4 -2
  42. data/app/views/collavre/shared/_custom_theme_style.html.erb +6 -2
  43. data/app/views/collavre/shared/navigation/_search_form.html.erb +0 -17
  44. data/app/views/collavre/users/_trigger_field.html.erb +51 -0
  45. data/app/views/collavre/users/edit_ai.html.erb +1 -5
  46. data/app/views/collavre/users/new_ai.html.erb +1 -5
  47. data/config/locales/comments.en.yml +8 -0
  48. data/config/locales/comments.ko.yml +8 -0
  49. data/config/locales/users.en.yml +10 -0
  50. data/config/locales/users.ko.yml +10 -0
  51. data/config/routes.rb +1 -1
  52. data/db/migrate/20260409000000_add_source_topic_id_to_topics.rb +5 -0
  53. data/lib/collavre/version.rb +1 -1
  54. metadata +9 -4
  55. data/app/helpers/collavre/user_themes_helper.rb +0 -4
  56. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -36
  57. data/app/views/collavre/creatives/_share_button.html.erb +0 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e41b11d4cc034b5427a84b2bcc4b2061d2400ec13a3fd05baf2783a37124c6c5
4
- data.tar.gz: f50467778a86b0dc89a1bea7f2c684f1c9bad3828db6b0c49f6c284c1cd7709c
3
+ metadata.gz: e56759c8d6833552a9d5e49b19745aca7d0a58d56ad3c3a7e6d502ea54d6b156
4
+ data.tar.gz: 37a2481e6fc5abc1077167d481abb51b118f256de05c704cdd64381eb090562a
5
5
  SHA512:
6
- metadata.gz: 70a7f5c8b10e0fb95d3cc7e3ca739c2d894e418e8936f141c99b69a339ec811556564c376e4e4b3624c8a375d8477dc676f915e4bcbdc1c532789b7234ee1b26
7
- data.tar.gz: 867618e8aff1be6f2d24735eceae6aeb6544ce85dccf7b7f23bbad9a4d0cb985f6131ed0a18fc6752ad897dc0e42c52446556fff9dab25c5d5ed5849c73e714e
6
+ metadata.gz: 9261164e029e28e9303f30879740f5dbd3ccbb6779b0767cd8488920e56f0037f63159fcdf64bd12dbb7788a22f5e1718fc468f616a02f59553a67713dfa422c
7
+ data.tar.gz: 5856204cfe3ff27016d1e0f5a54f3de43cf839911fae1dba656200679c941c837b4f30ded9e9f94153ecea2972c8797a0554f81c58df27123f1dc0d66ed7b30a
@@ -43,7 +43,7 @@ body.chat-fullscreen {
43
43
  border-radius: 0;
44
44
  box-shadow: none;
45
45
  border: none;
46
- z-index: 9999;
46
+ z-index: var(--layer-fullscreen);
47
47
  box-sizing: border-box;
48
48
  padding: 0.5em;
49
49
  }
@@ -478,6 +478,11 @@ body.chat-fullscreen {
478
478
  white-space: nowrap;
479
479
  }
480
480
 
481
+ .inbox-reply-btn {
482
+ font-size: var(--text-0) !important;
483
+ white-space: nowrap;
484
+ }
485
+
481
486
  .comment-highlight {
482
487
  background: color-mix(in srgb, var(--color-active) 15%, transparent) !important;
483
488
  transition: background 0.3s ease;
@@ -599,7 +604,7 @@ body.chat-fullscreen {
599
604
  border: 1px solid var(--color-border);
600
605
  border-radius: 8px;
601
606
  box-shadow: var(--shadow-3);
602
- z-index: 9999;
607
+ z-index: var(--layer-fullscreen);
603
608
  }
604
609
 
605
610
  #global-reaction-picker {
@@ -1510,6 +1515,15 @@ body.chat-fullscreen {
1510
1515
  opacity: 0.5;
1511
1516
  font-style: italic;
1512
1517
  }
1518
+ .topic-branch-icon {
1519
+ font-size: 0.75em;
1520
+ opacity: 0.6;
1521
+ margin-right: 1px;
1522
+ cursor: pointer;
1523
+ }
1524
+ .topic-branch-icon:hover {
1525
+ opacity: 1;
1526
+ }
1513
1527
  .archive-topic-btn,
1514
1528
  .unarchive-topic-btn {
1515
1529
  background: none;
@@ -145,6 +145,7 @@
145
145
  --layer-popup: 100;
146
146
  --layer-modal: 1000;
147
147
  --layer-toast: 2000;
148
+ --layer-fullscreen: 9999;
148
149
  --layer-important: 2147483647;
149
150
 
150
151
  /* ============================================================================
@@ -86,6 +86,12 @@
86
86
  z-index: calc(var(--layer-modal) + 10);
87
87
  }
88
88
 
89
+ /* When chat is fullscreen, these modals must sit above it */
90
+ body.chat-fullscreen #link-creative-modal,
91
+ body.chat-fullscreen #topic-search-modal {
92
+ z-index: calc(var(--layer-fullscreen) + 1);
93
+ }
94
+
89
95
  .common-popup {
90
96
  position: absolute;
91
97
  z-index: var(--layer-modal);
@@ -26,8 +26,7 @@ module Collavre
26
26
  private
27
27
 
28
28
  def unread_count_for_creative
29
- visible_comments = @creative.comments.where(private: false)
30
- .or(@creative.comments.where(user_id: @user.id))
29
+ visible_comments = @creative.comments.visible_to(@user)
31
30
  pointer = CommentReadPointer.find_by(user: @user, creative: @creative)
32
31
  last_read_id = pointer&.last_read_comment_id
33
32
 
@@ -2,7 +2,7 @@ module Collavre
2
2
  class CommentReadPointersController < ApplicationController
3
3
  def update
4
4
  creative = Creative.find(params[:creative_id]).effective_origin
5
- last_id = creative.comments.where("comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?", false, Current.user.id, Current.user.id).maximum(:id)
5
+ last_id = creative.comments.visible_to(Current.user).maximum(:id)
6
6
  pointer = CommentReadPointer.find_or_initialize_by(user: Current.user, creative: creative)
7
7
 
8
8
  previous_last_read_id = pointer.last_read_comment_id
@@ -1,6 +1,8 @@
1
1
  module Collavre
2
2
  module Comments
3
3
  class ReactionsController < ApplicationController
4
+ include Collavre::Comments::CommentScoping
5
+
4
6
  before_action :set_creative
5
7
  before_action :set_comment
6
8
  before_action :authorize_feedback!
@@ -48,22 +50,6 @@ module Collavre
48
50
  CommentReaction.broadcast_reaction_update(@comment)
49
51
  end
50
52
 
51
- def set_creative
52
- @creative = Creative.find(params[:creative_id]).effective_origin
53
- end
54
-
55
- def set_comment
56
- comment_id = params[:comment_id] || params[:id]
57
- @comment = @creative.comments
58
- .where(
59
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
60
- false,
61
- Current.user.id,
62
- Current.user.id
63
- )
64
- .find(comment_id)
65
- end
66
-
67
53
  def authorize_feedback!
68
54
  return if @creative.has_permission?(Current.user, :feedback)
69
55
 
@@ -3,6 +3,8 @@
3
3
  module Collavre
4
4
  module Comments
5
5
  class VersionsController < ApplicationController
6
+ include Collavre::Comments::CommentScoping
7
+
6
8
  before_action :set_creative
7
9
  before_action :set_comment
8
10
 
@@ -62,21 +64,6 @@ module Collavre
62
64
  end
63
65
 
64
66
  private
65
-
66
- def set_creative
67
- @creative = Creative.find(params[:creative_id]).effective_origin
68
- end
69
-
70
- def set_comment
71
- @comment = @creative.comments
72
- .where(
73
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
74
- false,
75
- Current.user.id,
76
- Current.user.id
77
- )
78
- .find(params[:comment_id])
79
- end
80
67
  end
81
68
  end
82
69
  end
@@ -1,5 +1,6 @@
1
1
  module Collavre
2
2
  class CommentsController < ApplicationController
3
+ include Collavre::Comments::CommentScoping
3
4
  include Collavre::Comments::ApprovalActions
4
5
  include Collavre::Comments::Conversion
5
6
  include Collavre::Comments::BatchOperations
@@ -26,12 +27,7 @@ module Collavre
26
27
  def index
27
28
  limit = 20
28
29
 
29
- visible_scope = @creative.comments.where(
30
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
31
- false,
32
- Current.user.id,
33
- Current.user.id
34
- )
30
+ visible_scope = @creative.comments.visible_to(Current.user)
35
31
  scope = visible_scope.with_attached_images.includes(:topic, :comment_reactions, :comment_versions, :snapshot_as_result)
36
32
 
37
33
  if params[:search].present?
@@ -185,6 +181,9 @@ module Collavre
185
181
  @comment.skip_dispatch = true
186
182
  end
187
183
  if @comment.save
184
+ # Cross-post inbox inline replies to the original creative/topic
185
+ InboxReplyService.call(@comment)
186
+
188
187
  # Dispatch is handled by Comment#after_create_commit callback
189
188
  @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
190
189
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }, status: :created
@@ -353,17 +352,6 @@ module Collavre
353
352
  end
354
353
  end
355
354
 
356
- def set_comment
357
- @comment = @creative.comments
358
- .where(
359
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
360
- false,
361
- Current.user.id,
362
- Current.user.id
363
- )
364
- .find(params[:id])
365
- end
366
-
367
355
  def comment_params
368
356
  params.require(:comment).permit(:content, :private, :topic_id, :quoted_comment_id, :quoted_text, :review_type, images: [])
369
357
  end
@@ -528,7 +528,7 @@ module Collavre
528
528
  end
529
529
 
530
530
  topic = creative.topics.find_by(name: "Drop Trigger")
531
- agent = parent.all_shared_users(:write).map(&:user).find(&:ai_user?)
531
+ agent = parent.find_ai_agent(:write)
532
532
  unless topic && agent
533
533
  Rails.logger.warn("[TriggerAction] resume: missing topic=#{topic&.id} or agent for creative #{creative.id}")
534
534
  return
@@ -565,7 +565,7 @@ module Collavre
565
565
  end
566
566
 
567
567
  topic = creative.topics.find_by(name: "Drop Trigger")
568
- agent = parent.all_shared_users(:write).map(&:user).find(&:ai_user?)
568
+ agent = parent.find_ai_agent(:write)
569
569
  unless topic && agent
570
570
  Rails.logger.warn("[TriggerAction] restart: missing topic=#{topic&.id} or agent for creative #{creative.id}")
571
571
  return
@@ -593,7 +593,7 @@ module Collavre
593
593
  end
594
594
 
595
595
  def notify_drop_trigger_missing_agent!(creative)
596
- return if creative.all_shared_users(:write).map(&:user).any?(&:ai_user?)
596
+ return if creative.find_ai_agent(:write)
597
597
 
598
598
  topic = creative.topics.find_or_create_by!(name: "Drop Trigger") do |t|
599
599
  t.user = creative.user
@@ -24,31 +24,7 @@ module Collavre
24
24
  private
25
25
 
26
26
  def abort_openclaw_session(task)
27
- return unless task.agent.llm_vendor&.downcase == "openclaw"
28
- return unless defined?(CollavreOpenclaw::ConnectionManager)
29
-
30
- conn = CollavreOpenclaw::ConnectionManager.instance.connection_for(task.agent)
31
- session_key = build_session_key(task)
32
- conn.chat_abort(session_key: session_key)
33
- rescue StandardError => e
34
- Rails.logger.warn("[TasksController] OpenClaw abort failed: #{e.message}")
35
- end
36
-
37
- def build_session_key(task)
38
- payload = task.trigger_event_payload || {}
39
- creative = task.creative || Creative.find_by(id: payload.dig("creative", "id"))
40
- comment = Comment.find_by(id: payload.dig("comment", "id"))
41
-
42
- CollavreOpenclaw::OpenclawAdapter.new(
43
- user: task.agent,
44
- system_prompt: "",
45
- context: {
46
- creative: creative,
47
- user: task.agent,
48
- task: task,
49
- comment: comment
50
- }
51
- ).session_key
27
+ Collavre::OpenclawAbortService.call(agent: task.agent, task: task)
52
28
  end
53
29
  end
54
30
  end
@@ -17,12 +17,16 @@ module Collavre
17
17
  .pick(:last_topic_id)
18
18
  end
19
19
 
20
+ system_topic_id = @creative.inbox? ? @creative.topics.find_by(name: Creative::SYSTEM_TOPIC_NAME)&.id : nil
21
+
20
22
  render json: {
21
23
  topics: active_topics.map { |t| topic_json(t) },
22
24
  archived_topics: archived_topics,
23
25
  can_manage: can_manage,
24
26
  can_create_topic: can_create_topic,
25
- last_topic_id: last_topic_id
27
+ last_topic_id: last_topic_id,
28
+ is_inbox: @creative.inbox?,
29
+ system_topic_id: system_topic_id
26
30
  }
27
31
  end
28
32
 
@@ -268,7 +272,7 @@ module Collavre
268
272
  end
269
273
 
270
274
  def topic_json(topic)
271
- data = topic.slice(:id, :name)
275
+ data = topic.slice(:id, :name, :source_topic_id)
272
276
  agent = topic.instance_variable_get(:@_primary_agent) || topic.primary_agent
273
277
  if agent
274
278
  data[:primary_agent] = agent_json(agent)
@@ -277,7 +281,7 @@ module Collavre
277
281
  end
278
282
 
279
283
  def topic_json_with_agent(topic, agent)
280
- data = topic.slice(:id, :name)
284
+ data = topic.slice(:id, :name, :source_topic_id)
281
285
  data[:primary_agent] = agent_json(agent)
282
286
  data
283
287
  end
@@ -14,10 +14,7 @@ module Collavre
14
14
  is_admin = @creative.has_permission?(Current.user, :admin)
15
15
  is_creative_owner = @creative.user == Current.user
16
16
 
17
- visible_scope = @creative.comments.where(
18
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
19
- false, Current.user.id, Current.user.id
20
- )
17
+ visible_scope = @creative.comments.visible_to(Current.user)
21
18
  comments = visible_scope.where(id: comment_ids).to_a
22
19
 
23
20
  if comments.length != comment_ids.length
@@ -47,10 +44,7 @@ module Collavre
47
44
  render json: { error: I18n.t("collavre.comments.merge.not_authorized") }, status: :forbidden and return
48
45
  end
49
46
 
50
- visible_scope = @creative.comments.where(
51
- "comments.private = ? OR comments.user_id = ? OR comments.approver_id = ?",
52
- false, Current.user.id, Current.user.id
53
- )
47
+ visible_scope = @creative.comments.visible_to(Current.user)
54
48
  comments = visible_scope.where(id: comment_ids).to_a
55
49
 
56
50
  if comments.length != comment_ids.length
@@ -82,6 +76,18 @@ module Collavre
82
76
  rescue ActiveRecord::RecordInvalid => e
83
77
  render json: { error: e.record.errors.full_messages.to_sentence.presence || I18n.t("collavre.comments.move_error") }, status: :unprocessable_entity
84
78
  end
79
+
80
+ def branch
81
+ source_topic = params[:topic_id].present? ? @creative.topics.find(params[:topic_id]) : nil
82
+ new_topic = TopicBranchService.new(creative: @creative, user: Current.user, source_topic: source_topic).call(
83
+ comment_ids: params[:comment_ids]
84
+ )
85
+ render json: { success: true, topic: new_topic.slice(:id, :name, :source_topic_id) }, status: :created
86
+ rescue TopicBranchService::BranchError => e
87
+ render json: { error: e.message }, status: :unprocessable_entity
88
+ rescue ActiveRecord::RecordInvalid => e
89
+ render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
90
+ end
85
91
  end
86
92
  end
87
93
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Comments
5
+ module CommentScoping
6
+ extend ActiveSupport::Concern
7
+
8
+ private
9
+
10
+ def set_creative
11
+ @creative = Creative.find(params[:creative_id]).effective_origin
12
+ end
13
+
14
+ def set_comment
15
+ comment_id = params[:comment_id] || params[:id]
16
+ @comment = @creative.comments.visible_to(Current.user).find(comment_id)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module IntegrationPermission
5
+ extend ActiveSupport::Concern
6
+
7
+ private
8
+
9
+ def ensure_read_permission
10
+ return if @creative.has_permission?(Current.user, :read)
11
+
12
+ render json: { error: integration_forbidden_message }, status: :forbidden
13
+ end
14
+
15
+ def ensure_admin_permission
16
+ return if @creative.has_permission?(Current.user, :admin)
17
+
18
+ render json: { error: integration_forbidden_message }, status: :forbidden
19
+ end
20
+
21
+ def ensure_write_permission
22
+ return if @creative.has_permission?(Current.user, :write)
23
+
24
+ render json: { error: integration_forbidden_message }, status: :forbidden
25
+ end
26
+
27
+ def integration_forbidden_message
28
+ "forbidden"
29
+ end
30
+ end
31
+ end
@@ -5,7 +5,7 @@ module Collavre
5
5
  included do
6
6
  before_action :set_user_for_ai_actions, only: [ :edit_ai, :update_ai ]
7
7
  before_action :verify_ai_user, only: [ :edit_ai, :update_ai ]
8
- before_action :verify_ai_user_authorization, only: [ :update_ai ]
8
+ before_action :verify_ai_user_authorization, only: [ :edit_ai, :update_ai ]
9
9
  end
10
10
 
11
11
  def new_ai
@@ -29,6 +29,7 @@ module Collavre
29
29
 
30
30
  def update
31
31
  @user = Collavre::User.find(params[:id])
32
+ return head :forbidden unless @user == Current.user || Current.user.system_admin?
32
33
  if @user.update(profile_params)
33
34
  redirect_to user_path(@user), notice: I18n.t("collavre.users.profile_updated")
34
35
  else
@@ -46,6 +47,7 @@ module Collavre
46
47
 
47
48
  def edit_password
48
49
  @user = Collavre::User.find(params[:id])
50
+ head :forbidden unless @user == Current.user || Current.user.system_admin?
49
51
  end
50
52
 
51
53
  def passkeys
@@ -78,8 +78,7 @@ module Collavre
78
78
  styles << render_theme_media_query(dark_theme, "dark")
79
79
  end
80
80
 
81
- # Safe: CSS generated from admin-configured theme settings (CSS variables only)
82
- styles.join("\n").html_safe # rubocop:disable Rails/OutputSafety
81
+ safe_join(styles, "\n")
83
82
  end
84
83
 
85
84
  private
@@ -111,7 +110,7 @@ module Collavre
111
110
  #
112
111
  def render_extension_slot(slot, **locals)
113
112
  entries = Collavre::ViewExtensions.for_slot(slot)
114
- return "".html_safe if entries.empty? # rubocop:disable Rails/OutputSafety
113
+ return safe_join([]) if entries.empty?
115
114
 
116
115
  safe_join(entries.map { |entry| render(partial: entry[:partial], locals: locals) })
117
116
  end
@@ -118,7 +118,7 @@ module Collavre
118
118
  if value == 1 && !completion_mark.nil?
119
119
  text = completion_mark
120
120
  end
121
- display_text = text.blank? ? "&nbsp;&nbsp;".html_safe : text
121
+ display_text = text.blank? ? "\u00A0\u00A0" : text
122
122
  content_tag(
123
123
  :span,
124
124
  display_text,
@@ -126,10 +126,6 @@ module Collavre
126
126
  )
127
127
  end
128
128
 
129
- def expanded_from_expanded_state(creative_id, expanded_state_map)
130
- !!(expanded_state_map && expanded_state_map[creative_id.to_s])
131
- end
132
-
133
129
  def render_creative_tree_markdown(creatives, level = 1, with_progress = false, max_depth: nil)
134
130
  return "" if creatives.blank?
135
131
  md = ""
@@ -184,9 +180,5 @@ module Collavre
184
180
  def markdown_links_to_html(text, image_refs = {})
185
181
  MarkdownConverter.markdown_to_html(text, image_refs)
186
182
  end
187
-
188
- def html_links_to_markdown(text)
189
- MarkdownConverter.html_to_markdown(text)
190
- end
191
183
  end
192
184
  end
@@ -47,12 +47,6 @@ module Collavre
47
47
  end
48
48
  end
49
49
 
50
- def render_mobile_navigation_item(item)
51
- content = render_navigation_item(item, mobile: true)
52
- return if content.blank?
53
- content_tag(:div, content)
54
- end
55
-
56
50
  def render_navigation_item_with_children(item, mobile: false)
57
51
  return unless navigation_item_visible?(item, desktop: !mobile)
58
52
 
@@ -0,0 +1,94 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ // Maps trigger type radio buttons to routing_expression values.
4
+ // Shows/hides keyword input and advanced expression textarea based on selection.
5
+ export default class extends Controller {
6
+ static targets = ['hiddenField', 'keywordInput', 'keywordGroup', 'advancedGroup', 'advancedInput']
7
+
8
+ static values = {
9
+ currentExpression: String
10
+ }
11
+
12
+ connect() {
13
+ this.detectTriggerType()
14
+ this.updateVisibility()
15
+ }
16
+
17
+ // Called when a radio button changes
18
+ change() {
19
+ this.updateVisibility()
20
+ this.updateExpression()
21
+ }
22
+
23
+ // Called when keyword input changes
24
+ keywordChanged() {
25
+ // Strip double quotes to prevent Liquid injection
26
+ const input = this.keywordInputTarget
27
+ const sanitized = input.value.replace(/"/g, '')
28
+ if (input.value !== sanitized) input.value = sanitized
29
+ this.updateExpression()
30
+ }
31
+
32
+ // Called when advanced expression changes
33
+ advancedChanged() {
34
+ this.hiddenFieldTarget.value = this.advancedInputTarget.value
35
+ }
36
+
37
+ get selectedType() {
38
+ const checked = this.element.querySelector('input[name="trigger_type"]:checked')
39
+ return checked?.value || 'mention'
40
+ }
41
+
42
+ detectTriggerType() {
43
+ const expr = (this.currentExpressionValue || '').trim()
44
+ if (!expr || expr === 'chat.mentioned_user.id == agent.id') {
45
+ this.selectRadio('mention')
46
+ } else if (expr === 'event_name == "comment_created"') {
47
+ this.selectRadio('auto')
48
+ } else {
49
+ const keywordMatch = expr.match(/^chat\.content\s+contains\s+"([^"]*)"$/)
50
+ if (keywordMatch) {
51
+ this.selectRadio('keyword')
52
+ this.keywordInputTarget.value = keywordMatch[1]
53
+ } else {
54
+ this.selectRadio('advanced')
55
+ this.advancedInputTarget.value = expr
56
+ }
57
+ }
58
+ }
59
+
60
+ selectRadio(value) {
61
+ const radio = this.element.querySelector(`input[name="trigger_type"][value="${value}"]`)
62
+ if (radio) radio.checked = true
63
+ }
64
+
65
+ updateVisibility() {
66
+ const type = this.selectedType
67
+ this.keywordGroupTarget.style.display = type === 'keyword' ? '' : 'none'
68
+ this.advancedGroupTarget.style.display = type === 'advanced' ? '' : 'none'
69
+ }
70
+
71
+ updateExpression() {
72
+ const type = this.selectedType
73
+ let expression = ''
74
+
75
+ switch (type) {
76
+ case 'auto':
77
+ expression = 'event_name == "comment_created"'
78
+ break
79
+ case 'mention':
80
+ expression = 'chat.mentioned_user.id == agent.id'
81
+ break
82
+ case 'keyword': {
83
+ const keyword = this.keywordInputTarget.value.trim().replace(/"/g, '')
84
+ expression = keyword ? `chat.content contains "${keyword}"` : ''
85
+ break
86
+ }
87
+ case 'advanced':
88
+ expression = this.advancedInputTarget.value
89
+ break
90
+ }
91
+
92
+ this.hiddenFieldTarget.value = expression
93
+ }
94
+ }
@@ -121,7 +121,7 @@ export default class extends Controller {
121
121
 
122
122
  // Text selection quote support
123
123
  this.handleMouseUp = this.handleMouseUp.bind(this)
124
- this.element.addEventListener('mouseup', this.handleMouseUp)
124
+ document.addEventListener('mouseup', this.handleMouseUp)
125
125
 
126
126
  this.currentUserId = document.body.dataset.currentUserId
127
127
  const commentAuthorId = this.element.dataset.userId
@@ -223,7 +223,7 @@ export default class extends Controller {
223
223
  clearTimeout(this._streamingTimeout)
224
224
  this._streamingTimeout = null
225
225
  }
226
- this.element.removeEventListener('mouseup', this.handleMouseUp)
226
+ document.removeEventListener('mouseup', this.handleMouseUp)
227
227
  this.hideReviewPopup()
228
228
  if (this._reviewPopupEl) {
229
229
  this._reviewPopupEl.remove()