collavre 0.20.2 → 0.21.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  7. data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
  8. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  9. data/app/controllers/collavre/application_controller.rb +27 -0
  10. data/app/controllers/collavre/channels_controller.rb +23 -0
  11. data/app/controllers/collavre/creatives_controller.rb +50 -6
  12. data/app/controllers/collavre/landing_controller.rb +8 -0
  13. data/app/controllers/collavre/passwords_controller.rb +1 -0
  14. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  15. data/app/controllers/collavre/topics_controller.rb +21 -30
  16. data/app/helpers/collavre/comments_helper.rb +7 -0
  17. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  18. data/app/javascript/controllers/comment_controller.js +9 -0
  19. data/app/javascript/controllers/comments/form_controller.js +4 -0
  20. data/app/javascript/controllers/comments/list_controller.js +10 -7
  21. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  22. data/app/javascript/controllers/comments/presence_controller.js +83 -1
  23. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  24. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  25. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  26. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  27. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  28. data/app/javascript/controllers/index.js +4 -1
  29. data/app/javascript/controllers/landing_video_controller.js +53 -0
  30. data/app/javascript/creatives/tree_renderer.js +6 -0
  31. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  32. data/app/javascript/lib/api/queue_manager.js +17 -5
  33. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  34. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  35. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  36. data/app/javascript/modules/command_args_form.js +22 -4
  37. data/app/javascript/modules/command_menu.js +27 -0
  38. data/app/javascript/modules/creative_row_editor.js +227 -17
  39. data/app/javascript/modules/html_content_empty.js +12 -0
  40. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  41. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  42. data/app/mailers/collavre/application_mailer.rb +1 -1
  43. data/app/models/collavre/channel/injected_message.rb +5 -0
  44. data/app/models/collavre/channel.rb +87 -0
  45. data/app/models/collavre/creative/describable.rb +65 -3
  46. data/app/models/collavre/creative.rb +2 -0
  47. data/app/models/collavre/integration_setting.rb +35 -0
  48. data/app/models/collavre/preview_channel.rb +93 -0
  49. data/app/models/collavre/system_setting.rb +13 -2
  50. data/app/models/collavre/topic.rb +3 -25
  51. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  52. data/app/services/collavre/ai_client.rb +3 -3
  53. data/app/services/collavre/channel_attacher.rb +58 -0
  54. data/app/services/collavre/comments/mcp_command.rb +31 -1
  55. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  56. data/app/services/collavre/google_calendar_service.rb +4 -2
  57. data/app/services/collavre/markdown_converter.rb +130 -15
  58. data/app/services/collavre/markdown_importer.rb +7 -2
  59. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  60. data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
  61. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  62. data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
  63. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  64. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  65. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  66. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  67. data/app/services/collavre/topic_branch_service.rb +34 -26
  68. data/app/views/admin/shared/_tabs.html.erb +1 -0
  69. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  70. data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
  71. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  72. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  73. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  74. data/app/views/collavre/comments/_comment.html.erb +6 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
  76. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  77. data/app/views/collavre/creatives/index.html.erb +10 -2
  78. data/app/views/collavre/landing/show.html.erb +130 -0
  79. data/app/views/layouts/collavre/landing.html.erb +33 -0
  80. data/config/locales/admin.en.yml +4 -2
  81. data/config/locales/admin.ko.yml +4 -2
  82. data/config/locales/channels.en.yml +11 -0
  83. data/config/locales/channels.ko.yml +11 -0
  84. data/config/locales/comments.en.yml +2 -0
  85. data/config/locales/comments.ko.yml +2 -0
  86. data/config/locales/creatives.en.yml +9 -0
  87. data/config/locales/creatives.ko.yml +8 -0
  88. data/config/locales/integrations.en.yml +44 -0
  89. data/config/locales/integrations.ko.yml +44 -0
  90. data/config/locales/landing.en.yml +51 -0
  91. data/config/locales/landing.ko.yml +51 -0
  92. data/config/routes.rb +18 -0
  93. data/db/migrate/20260526000000_create_channels.rb +42 -0
  94. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  95. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  96. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  97. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  98. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  99. data/db/seeds.rb +19 -0
  100. data/lib/collavre/aws_credentials.rb +75 -0
  101. data/lib/collavre/engine.rb +51 -0
  102. data/lib/collavre/integration_settings/key_definition.rb +29 -0
  103. data/lib/collavre/integration_settings/registry.rb +55 -0
  104. data/lib/collavre/integration_settings/resolver.rb +71 -0
  105. data/lib/collavre/integration_settings.rb +46 -0
  106. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  107. data/lib/collavre/version.rb +1 -1
  108. data/lib/collavre.rb +3 -0
  109. metadata +52 -1
@@ -0,0 +1,23 @@
1
+ module Collavre
2
+ class ChannelsController < ApplicationController
3
+ include Collavre::CreativePermissionGuard
4
+
5
+ before_action :set_channel
6
+ before_action :require_creative_write!
7
+
8
+ def destroy
9
+ @channel.dismiss!
10
+ head :no_content
11
+ end
12
+
13
+ private
14
+
15
+ def set_channel
16
+ # Look up among not-yet-dismissed channels (active OR detached). The X
17
+ # button must work on detached chips too — those linger so the user can
18
+ # still see the final merge/close badge until they choose to clear it.
19
+ @channel = Channel.not_dismissed.find(params[:id])
20
+ @creative = @channel.topic.creative.effective_origin
21
+ end
22
+ end
23
+ end
@@ -118,6 +118,7 @@ module Collavre
118
118
 
119
119
  trigger_loop_data = @creative.data&.dig("trigger", "loop")
120
120
  parent_trigger_enabled = @creative.parent&.drop_trigger_enabled? || false
121
+ can_edit = @creative.has_permission?(Current.user, :write)
121
122
 
122
123
  etag = [
123
124
  "creative",
@@ -132,7 +133,9 @@ module Collavre
132
133
  "trigger_v3",
133
134
  trigger_loop_data&.dig("state"),
134
135
  trigger_loop_data&.dig("current_iteration"),
135
- parent_trigger_enabled
136
+ parent_trigger_enabled,
137
+ "can_edit",
138
+ can_edit
136
139
  ].join(":")
137
140
 
138
141
  if stale?(etag: etag, last_modified: last_modified, public: false)
@@ -142,6 +145,13 @@ module Collavre
142
145
  else
143
146
  @creative.ancestors.count + 1
144
147
  end
148
+ sanitized_data = @creative.effective_origin(Set.new).data
149
+ # markdown_source is exposed via the top-level `markdown_source:` field for writers;
150
+ # exclude it from the editable `data` payload so the metadata YAML editor can't
151
+ # round-trip a stale copy back into data["markdown_source"] on update_metadata.
152
+ if sanitized_data.is_a?(Hash) && sanitized_data.key?("markdown_source")
153
+ sanitized_data = sanitized_data.except("markdown_source")
154
+ end
145
155
  render json: {
146
156
  id: @creative.id,
147
157
  description: @creative.effective_description,
@@ -153,10 +163,12 @@ module Collavre
153
163
  depth: depth,
154
164
  prompt: @creative.prompt_for(Current.user),
155
165
  has_children: children_count > 0,
156
- data: @creative.effective_origin(Set.new).data,
166
+ data: sanitized_data,
167
+ content_type: effective.data&.dig("content_type"),
168
+ markdown_source: can_edit ? effective.data&.dig("markdown_source") : nil,
157
169
  trigger_loop: trigger_loop_data,
158
170
  is_trigger_task: parent_trigger_enabled,
159
- can_edit: @creative.has_permission?(Current.user, :write)
171
+ can_edit: can_edit
160
172
  }
161
173
  end
162
174
  end
@@ -190,7 +202,16 @@ module Collavre
190
202
  @creative = result.creative
191
203
 
192
204
  if result.success?
193
- render json: { id: @creative.id }
205
+ # Expose the post-rewrite markdown source so the client can sync its
206
+ # textarea after the server replaces inline data: URIs with blob paths,
207
+ # matching the update endpoint contract. Without this, a freshly created
208
+ # markdown creative with a pasted data: URI would re-import the blob on
209
+ # the next keystroke save.
210
+ render json: {
211
+ id: @creative.id,
212
+ content_type: @creative.data&.dig("content_type"),
213
+ markdown_source: @creative.data&.dig("markdown_source")
214
+ }
194
215
  else
195
216
  render json: { errors: result.errors }, status: :unprocessable_entity
196
217
  end
@@ -259,8 +280,17 @@ module Collavre
259
280
  id: base.id,
260
281
  progress: base.progress,
261
282
  progress_html: view_context.render_creative_progress(base),
262
- has_children: base.children.exists?
283
+ has_children: base.children.exists?,
284
+ content_type: base.data&.dig("content_type")
263
285
  }
286
+ # Expose the post-rewrite markdown source so the client can sync its
287
+ # textarea after the server replaces inline data: URIs with blob paths.
288
+ # Gated on write permission so a read-only share recipient moving a
289
+ # linked creative (parent_id-only PATCH bypasses the origin_changes
290
+ # write check) cannot read the origin's raw Markdown source.
291
+ if @creative.has_permission?(Current.user, :write)
292
+ response_data[:markdown_source] = base.data&.dig("markdown_source")
293
+ end
264
294
  # Build ancestor chain for progress updates (closure_tree: 1 SELECT via hierarchy table)
265
295
  ancestor_records = base.ancestors.order(:id)
266
296
  if ancestor_records.any?
@@ -359,6 +389,20 @@ module Collavre
359
389
  render json: { error: "Invalid JSON: #{e.message}" }, status: :unprocessable_entity
360
390
  return
361
391
  end
392
+ unless new_data.is_a?(Hash)
393
+ render json: { error: t("collavre.creatives.errors.metadata_must_be_object") }, status: :unprocessable_entity
394
+ return
395
+ end
396
+ # Reserved markdown fields are not editable via metadata; preserve current values so a stale
397
+ # YAML payload from the metadata popup can't overwrite a concurrent markdown edit.
398
+ current_data = creative.data || {}
399
+ %w[markdown_source content_type].each do |key|
400
+ if current_data.key?(key)
401
+ new_data[key] = current_data[key]
402
+ else
403
+ new_data.delete(key)
404
+ end
405
+ end
362
406
  previous_enabled = creative.drop_trigger_enabled?
363
407
 
364
408
  if creative.update(data: new_data)
@@ -499,7 +543,7 @@ module Collavre
499
543
  end
500
544
 
501
545
  def creative_params
502
- params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id)
546
+ params.require(:creative).permit(:description, :progress, :parent_id, :sequence, :origin_id, :markdown_source, :content_type_input)
503
547
  end
504
548
 
505
549
  def any_filter_active?
@@ -0,0 +1,8 @@
1
+ module Collavre
2
+ class LandingController < ApplicationController
3
+ allow_unauthenticated_access
4
+
5
+ def show
6
+ end
7
+ end
8
+ end
@@ -20,6 +20,7 @@ module Collavre
20
20
 
21
21
  def update
22
22
  if @user.update(params.permit(:password, :password_confirmation))
23
+ @user.update_column(:email_verified_at, Time.current) unless @user.email_verified?
23
24
  redirect_to new_session_path, notice: "Password has been reset."
24
25
  else
25
26
  redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Serves ActiveStorage blobs through a CDN-friendly path:
5
+ # /public-assets/blobs/:signed_id/*filename
6
+ #
7
+ # The signed_id is the only capability — no auth required. This makes the URL
8
+ # safe to embed in landing pages and other publicly-rendered HTML. CloudFront
9
+ # caches by full URL, so rotating signed_id (re-attach) invalidates effectively.
10
+ class PublicAssetsController < ActionController::Base
11
+ include ActiveStorage::SetCurrent
12
+ include ActiveStorage::Streaming
13
+
14
+ PUBLIC_CACHE_CONTROL = "public, max-age=31536000, immutable"
15
+
16
+ def show
17
+ blob = ActiveStorage::Blob.find_signed!(params[:signed_id])
18
+ response.headers["Cache-Control"] = PUBLIC_CACHE_CONTROL
19
+ send_blob_stream blob, disposition: "inline"
20
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
21
+ head :not_found
22
+ end
23
+ end
24
+ end
@@ -3,7 +3,7 @@ module Collavre
3
3
  include Collavre::CreativePermissionGuard
4
4
 
5
5
  before_action :set_creative
6
- before_action :require_creative_read!, only: %i[next_name]
6
+ before_action :require_creative_read!, only: %i[next_name channel_chips]
7
7
  before_action :require_creative_admin!, only: %i[update destroy move reorder]
8
8
  before_action :require_creative_write!, only: %i[create archive unarchive set_primary_agent]
9
9
 
@@ -12,8 +12,15 @@ module Collavre
12
12
  can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
13
13
  can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
14
14
 
15
- active_topics = @creative.topics.active.order(:created_at).to_a
16
- preload_primary_agents(active_topics)
15
+ # Eagerly ensure Main (and System for inboxes) exist BEFORE loading
16
+ # active_topics. Otherwise the first inbox visit sees no System topic in
17
+ # the sidebar, and when a later notification creates it via
18
+ # find_or_create_by!, the unread badge appears but the user has no way to
19
+ # open the topic.
20
+ main_topic = @creative.main_topic(fallback_user: Current.user)
21
+ system_topic = @creative.inbox? ? @creative.system_topic(fallback_user: Current.user) : nil
22
+
23
+ active_topics = @creative.topics.active.includes(primary_agent: { avatar_attachment: :blob }).order(:created_at).to_a
17
24
  archived_topics = @creative.topics.archived.order(:created_at)
18
25
 
19
26
  last_topic_id = if Current.user
@@ -22,8 +29,8 @@ module Collavre
22
29
  .pick(:last_topic_id)
23
30
  end
24
31
 
25
- system_topic_id = @creative.inbox? ? @creative.topics.find_by(name: Creative::SYSTEM_TOPIC_NAME)&.id : nil
26
- main_topic_id = @creative.main_topic(fallback_user: Current.user).id
32
+ system_topic_id = system_topic&.id
33
+ main_topic_id = main_topic.id
27
34
 
28
35
  render json: {
29
36
  topics: active_topics.map { |t| topic_json(t) },
@@ -33,7 +40,8 @@ module Collavre
33
40
  last_topic_id: last_topic_id,
34
41
  is_inbox: @creative.inbox?,
35
42
  system_topic_id: system_topic_id,
36
- main_topic_id: main_topic_id
43
+ main_topic_id: main_topic_id,
44
+ effective_creative_id: @creative.id
37
45
  }
38
46
  end
39
47
 
@@ -73,6 +81,11 @@ module Collavre
73
81
  render json: { name: generate_next_topic_name }
74
82
  end
75
83
 
84
+ def channel_chips
85
+ topic = @creative.topics.find(params[:id])
86
+ render partial: "collavre/comments/channel_chips", locals: { topic: topic }
87
+ end
88
+
76
89
  def update
77
90
  topic = @creative.topics.find(params[:id])
78
91
 
@@ -194,32 +207,10 @@ module Collavre
194
207
  "#{prefix}#{next_number}"
195
208
  end
196
209
 
197
- # Batch-load primary agents for all topics to avoid N+1 queries
198
- def preload_primary_agents(topics)
199
- topic_ids = topics.map(&:id)
200
- return if topic_ids.empty?
201
-
202
- policies = OrchestratorPolicy.where(
203
- policy_type: "arbitration",
204
- scope_type: "Topic",
205
- scope_id: topic_ids
206
- ).index_by { |p| p.scope_id.to_i }
207
-
208
- agent_ids = policies.values.filter_map { |p| p.config&.dig("primary_agent_id") }
209
- agents = agent_ids.present? ? User.where(id: agent_ids).includes(avatar_attachment: :blob).index_by(&:id) : {}
210
-
211
- topics.each do |topic|
212
- policy = policies[topic.id]
213
- agent_id = policy&.config&.dig("primary_agent_id")
214
- topic.instance_variable_set(:@_primary_agent, agents&.dig(agent_id))
215
- end
216
- end
217
-
218
210
  def topic_json(topic)
219
211
  data = topic.slice(:id, :name, :source_topic_id)
220
- agent = topic.instance_variable_get(:@_primary_agent) || topic.primary_agent
221
- if agent
222
- data[:primary_agent] = agent_json(agent)
212
+ if topic.primary_agent
213
+ data[:primary_agent] = agent_json(topic.primary_agent)
223
214
  end
224
215
  data
225
216
  end
@@ -5,5 +5,12 @@ module Collavre
5
5
  rescue JSON::ParserError, TypeError
6
6
  comment.action.to_s
7
7
  end
8
+
9
+ def comment_action_markdown(comment)
10
+ parsed = JSON.parse(comment.action)
11
+ parsed["markdown"] if parsed.is_a?(Hash) && parsed["markdown"].present?
12
+ rescue JSON::ParserError, TypeError
13
+ nil
14
+ end
8
15
  end
9
16
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module PublicAssetsHelper
5
+ # Returns a URL for serving an ActiveStorage blob through the public-assets
6
+ # proxy. When PUBLIC_ASSETS_HOST is set (e.g. CloudFront domain) the URL is
7
+ # absolute; otherwise relative so the browser uses the current host.
8
+ def public_asset_url(blob)
9
+ path = "/public-assets/blobs/#{blob.signed_id}/#{blob.filename.sanitized}"
10
+ host = Collavre::IntegrationSettings.fetch(:public_assets_host)
11
+ host ? "#{host.chomp('/')}#{path}" : path
12
+ end
13
+ end
14
+ end
@@ -123,6 +123,15 @@ export default class extends Controller {
123
123
  }
124
124
  }
125
125
 
126
+ const actionMarkdownElement = this.element.querySelector('.comment-action-markdown')
127
+ if (actionMarkdownElement && actionMarkdownElement.dataset.rendered !== 'true') {
128
+ const text = actionMarkdownElement.textContent || ''
129
+ actionMarkdownElement.innerHTML = renderCommentMarkdown(text)
130
+ addTableDownloadButtons(actionMarkdownElement)
131
+ renderMermaidDiagrams(actionMarkdownElement)
132
+ actionMarkdownElement.dataset.rendered = 'true'
133
+ }
134
+
126
135
  // Text selection quote support
127
136
  this.handleMouseUp = this.handleMouseUp.bind(this)
128
137
  document.addEventListener('mouseup', this.handleMouseUp)
@@ -145,6 +145,10 @@ export default class extends Controller {
145
145
  onPopupOpened({ creativeId, canComment }) {
146
146
  this.creativeId = creativeId
147
147
  this.element.dataset.creativeId = creativeId || ''
148
+ // Stale topic ids from the previous creative are cleared by the popup
149
+ // controller BEFORE topics loadTopics() dispatches comments--topics:change,
150
+ // so by the time we get here, currentTopicId already reflects the new
151
+ // creative's restored topic. Do not re-clear it.
148
152
  this.formTarget.style.display = canComment ? '' : 'none'
149
153
  this.resetForm()
150
154
  if (canComment && this.shouldAutoFocusOnOpen()) {
@@ -1123,25 +1123,28 @@ export default class extends Controller {
1123
1123
  openActionEditor(container) {
1124
1124
  if (!container) return
1125
1125
  const json = container.querySelector('.comment-action-json')
1126
+ const md = container.querySelector('.comment-action-markdown')
1126
1127
  const form = container.querySelector('.comment-action-edit-form')
1127
1128
  const btn = container.querySelector('.edit-comment-action-btn')
1128
1129
  const txt = form?.querySelector('.comment-action-edit-textarea')
1129
- if (json && form && txt) {
1130
- txt.value = json.textContent || ''
1131
- form.style.display = 'block'
1132
- if (btn) btn.style.display = 'none'
1133
- json.style.display = 'none'
1134
- txt.focus()
1135
- }
1130
+ if (!form || !txt) return
1131
+ if (json) txt.value = json.textContent || ''
1132
+ form.style.display = 'block'
1133
+ if (btn) btn.style.display = 'none'
1134
+ if (json) json.style.display = 'none'
1135
+ if (md) md.style.display = 'none'
1136
+ txt.focus()
1136
1137
  }
1137
1138
 
1138
1139
  closeActionEditor(container) {
1139
1140
  if (!container) return
1140
1141
  const json = container.querySelector('.comment-action-json')
1142
+ const md = container.querySelector('.comment-action-markdown')
1141
1143
  const form = container.querySelector('.comment-action-edit-form')
1142
1144
  const btn = container.querySelector('.edit-comment-action-btn')
1143
1145
  if (form) form.style.display = 'none'
1144
1146
  if (json) json.style.display = ''
1147
+ if (md) md.style.display = ''
1145
1148
  if (btn) btn.style.display = ''
1146
1149
  }
1147
1150
 
@@ -289,6 +289,15 @@ export default class extends Controller {
289
289
 
290
290
  async notifyChildControllers({ creativeId, canComment, highlightId }) {
291
291
  this.topicsController?.clearOverrideTopicId()
292
+ // Drop the previous creative's topic selection from the form controller
293
+ // synchronously, BEFORE topics loadTopics() dispatches `comments--topics:change`
294
+ // (which repopulates these via handleTopicChange). Doing it later — e.g. in
295
+ // formController.onPopupOpened, which runs after the topics await — would
296
+ // erase the topic that restoreSelection() just restored from the server.
297
+ if (this.formController) {
298
+ this.formController.currentTopicId = ''
299
+ this.formController._mainTopicId = null
300
+ }
292
301
  // Pre-set creativeId on list controller BEFORE loading topics.
293
302
  // Topics loading triggers a change event that list controller handles.
294
303
  // Without this, list controller still holds the previous creative's ID
@@ -7,7 +7,7 @@ const TYPING_TIMEOUT = 3000
7
7
  const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
8
8
 
9
9
  export default class extends Controller {
10
- static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox']
10
+ static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox', 'channelChips']
11
11
 
12
12
  connect() {
13
13
  this.creativeId = null
@@ -24,11 +24,13 @@ export default class extends Controller {
24
24
  this.handleInput = this.handleInput.bind(this)
25
25
  this.handleFocus = this.handleFocus.bind(this)
26
26
  this.handleBlur = this.handleBlur.bind(this)
27
+ this.handleTopicChange = this.handleTopicChange.bind(this)
27
28
 
28
29
  this.textareaTarget.addEventListener('input', this.handleInput)
29
30
  this.textareaTarget.addEventListener('focus', this.handleFocus)
30
31
  this.textareaTarget.addEventListener('blur', this.handleBlur)
31
32
  this.privateCheckboxTarget?.addEventListener('change', () => this.stoppedTyping())
33
+ this.element.addEventListener('comments--topics:change', this.handleTopicChange)
32
34
  }
33
35
 
34
36
  disconnect() {
@@ -36,6 +38,16 @@ export default class extends Controller {
36
38
  this.textareaTarget.removeEventListener('input', this.handleInput)
37
39
  this.textareaTarget.removeEventListener('focus', this.handleFocus)
38
40
  this.textareaTarget.removeEventListener('blur', this.handleBlur)
41
+ this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
42
+ }
43
+
44
+ handleTopicChange(event) {
45
+ const topicId = event.detail?.topicId
46
+ if (topicId) {
47
+ this.refreshChannelChips(topicId)
48
+ } else {
49
+ this.clearChannelChips()
50
+ }
39
51
  }
40
52
 
41
53
  get listController() {
@@ -56,6 +68,23 @@ export default class extends Controller {
56
68
  this.subscribe()
57
69
  this.renderParticipants([])
58
70
  this.renderTypingIndicator()
71
+ // Bootstrap chips for the topic that is already active when the popup opens.
72
+ // Without this, chips only appear after a `topics:change` event fires
73
+ // (i.e. a topic switch) or after a webhook arrives — leaving the user
74
+ // unable to detach existing channels until something else triggers a paint.
75
+ this.bootstrapChannelChips()
76
+ }
77
+
78
+ bootstrapChannelChips() {
79
+ const topicsCtrl = this.application.getControllerForElementAndIdentifier(
80
+ this.element, 'comments--topics'
81
+ )
82
+ const topicId = topicsCtrl?.currentTopicId
83
+ if (topicId) {
84
+ this.refreshChannelChips(topicId)
85
+ } else {
86
+ this.clearChannelChips()
87
+ }
59
88
  }
60
89
 
61
90
  onPopupClosed() {
@@ -201,6 +230,9 @@ export default class extends Controller {
201
230
  this.loadParticipants({ closeOnForbidden: shareChange.has_access === false })
202
231
  return
203
232
  }
233
+ if (data.channel_chips) {
234
+ this.refreshChannelChips(data.channel_chips.topic_id)
235
+ }
204
236
  if (data.agent_status) {
205
237
  const { id, name, status, task_id, creative_id: agentCreativeId } = data.agent_status
206
238
  // Only show typing indicator if agent is working on this specific creative
@@ -417,6 +449,56 @@ export default class extends Controller {
417
449
  .catch((err) => console.warn('[presence] cancel agent task failed:', err))
418
450
  }
419
451
 
452
+ clearChannelChips() {
453
+ const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
454
+ if (!target) return
455
+ target.innerHTML = ''
456
+ delete target.dataset.topicId
457
+ }
458
+
459
+ refreshChannelChips(topicId) {
460
+ const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
461
+ if (!target) return
462
+ if (!this.creativeId) return
463
+ if (!topicId) {
464
+ this.clearChannelChips()
465
+ return
466
+ }
467
+
468
+ // Source of truth for "what the user is looking at" is the topics
469
+ // controller's currentTopicId, NOT the chip container's data-topic-id:
470
+ // - On initial popup open the container is empty (no data-topic-id),
471
+ // so a stray broadcast for any topic in the same creative used to
472
+ // paint chips for the wrong topic.
473
+ // - After the user switches topics the container's data-topic-id is
474
+ // stale until something repaints it, blocking legit updates.
475
+ const topicsCtrl = this.application.getControllerForElementAndIdentifier(
476
+ this.element, 'comments--topics'
477
+ )
478
+ const activeTopicId = topicsCtrl?.currentTopicId || ''
479
+ if (String(activeTopicId) !== String(topicId)) return
480
+
481
+ fetch(`/creatives/${this.creativeId}/topics/${topicId}/channel_chips`, {
482
+ headers: { Accept: 'text/html' },
483
+ credentials: 'same-origin',
484
+ })
485
+ .then((r) => (r.ok ? r.text() : null))
486
+ .then((html) => {
487
+ if (html) target.outerHTML = html
488
+ })
489
+ .catch((err) => console.warn('[presence] refresh channel chips failed:', err))
490
+ }
491
+
492
+ detachChannel(event) {
493
+ const btn = event.currentTarget
494
+ const id = btn.dataset.channelId
495
+ if (!id) return
496
+ csrfFetch(`/channels/${id}`, {
497
+ method: 'DELETE',
498
+ headers: { Accept: 'application/json' },
499
+ }).catch((err) => console.warn('[presence] detach channel failed:', err))
500
+ }
501
+
420
502
  clearTypingTimers() {
421
503
  Object.values(this.typingTimers).forEach((timer) => clearTimeout(timer))
422
504
  this.typingTimers = {}
@@ -34,6 +34,15 @@ export default class extends Controller {
34
34
 
35
35
  onPopupOpened({ creativeId }) {
36
36
  this.creativeIdValue = creativeId
37
+ // Clear stale cached state from the previous creative — otherwise
38
+ // chat-context autofill (command_menu) reads stale values during the
39
+ // window between popup switch and the new topics fetch completing.
40
+ // form_controller's currentTopicId is cleared upstream in
41
+ // popup_controller.notifyChildControllers; here we clear our own
42
+ // mainTopicId (read directly as the autofill fallback) and the cached
43
+ // effective_creative_id. loadTopics() repopulates both.
44
+ delete this.element.dataset.effectiveCreativeId
45
+ this.mainTopicId = null
37
46
  this.subscribe()
38
47
  return this.loadTopics()
39
48
  }
@@ -88,6 +97,12 @@ export default class extends Controller {
88
97
  this.isInbox = !!data.is_inbox
89
98
  this.systemTopicId = data.system_topic_id ? String(data.system_topic_id) : null
90
99
  this.mainTopicId = data.main_topic_id ? String(data.main_topic_id) : null
100
+ // Expose effective origin id so chat-context autofill (slash commands)
101
+ // and any other consumer can target the same creative the server uses
102
+ // (linked creatives resolve params[:creative_id] through effective_origin).
103
+ if (data.effective_creative_id) {
104
+ this.element.dataset.effectiveCreativeId = String(data.effective_creative_id)
105
+ }
91
106
 
92
107
  // Migrate localStorage to server if server has no value
93
108
  this.migrateLocalStorage()
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const subscribeToCreatives = jest.fn()
8
+
9
+ jest.unstable_mockModule('../../../services/creatives_channel', () => ({
10
+ subscribeToCreatives,
11
+ }))
12
+
13
+ const { Application } = await import('@hotwired/stimulus')
14
+ const SyncController = (await import('../sync_controller')).default
15
+
16
+ describe('CreativesSyncController subscribe timing', () => {
17
+ let application
18
+ let container
19
+
20
+ beforeEach(() => {
21
+ subscribeToCreatives.mockReset()
22
+ subscribeToCreatives.mockReturnValue({
23
+ cleanup: jest.fn(),
24
+ sendEditing: jest.fn(),
25
+ sendStoppedEditing: jest.fn(),
26
+ })
27
+
28
+ container = document.createElement('div')
29
+ container.id = 'sync-root'
30
+ container.setAttribute('data-controller', 'creatives--sync')
31
+ container.setAttribute('data-creatives--sync-root-id-value', '991')
32
+ document.body.appendChild(container)
33
+
34
+ application = Application.start()
35
+ application.register('creatives--sync', SyncController)
36
+ })
37
+
38
+ afterEach(() => {
39
+ application.stop()
40
+ document.body.innerHTML = ''
41
+ })
42
+
43
+ test('does NOT subscribe immediately when rootIdValue > 0 — waits for tree to load', async () => {
44
+ await new Promise((resolve) => setTimeout(resolve, 0))
45
+
46
+ expect(subscribeToCreatives).not.toHaveBeenCalled()
47
+ })
48
+
49
+ test('subscribes after creative-tree:updated event fires', async () => {
50
+ await new Promise((resolve) => setTimeout(resolve, 0))
51
+ expect(subscribeToCreatives).not.toHaveBeenCalled()
52
+
53
+ container.dispatchEvent(
54
+ new CustomEvent('creative-tree:updated', { bubbles: true }),
55
+ )
56
+
57
+ expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
58
+ expect(subscribeToCreatives).toHaveBeenCalledWith(991, expect.any(Object))
59
+ })
60
+
61
+ test('subscribes only once even if creative-tree:updated fires multiple times', async () => {
62
+ await new Promise((resolve) => setTimeout(resolve, 0))
63
+
64
+ container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
65
+ container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
66
+ container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
67
+
68
+ expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
69
+ })
70
+
71
+ test('subscribes immediately when tree is already loaded (Turbo cache restore)', async () => {
72
+ // Simulate Turbo restoring a cached tree page: data-loaded is already set
73
+ // before sync_controller connects, so tree_controller skips load() and
74
+ // never dispatches creative-tree:updated.
75
+ const cached = document.createElement('div')
76
+ cached.id = 'sync-cached'
77
+ cached.setAttribute('data-controller', 'creatives--cached-sync')
78
+ cached.setAttribute('data-creatives--cached-sync-root-id-value', '991')
79
+ cached.setAttribute('data-loaded', 'true')
80
+ cached.innerHTML = '<creative-tree-row creative-id="1"></creative-tree-row>'
81
+ document.body.appendChild(cached)
82
+
83
+ application.register('creatives--cached-sync', SyncController)
84
+ await new Promise((resolve) => setTimeout(resolve, 0))
85
+
86
+ expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
87
+ expect(subscribeToCreatives).toHaveBeenCalledWith(991, expect.any(Object))
88
+ })
89
+ })