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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +83 -0
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/creatives_controller.rb +50 -6
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/passwords_controller.rb +1 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/topics_controller.rb +21 -30
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/controllers/comment_controller.js +9 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +10 -7
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +83 -1
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/creative/describable.rb +65 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/topic.rb +3 -25
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +6 -1
- data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +11 -0
- data/config/locales/channels.ko.yml +11 -0
- data/config/locales/comments.en.yml +2 -0
- data/config/locales/comments.ko.yml +2 -0
- data/config/locales/creatives.en.yml +9 -0
- data/config/locales/creatives.ko.yml +8 -0
- data/config/locales/integrations.en.yml +44 -0
- data/config/locales/integrations.ko.yml +44 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +18 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +51 -0
- data/lib/collavre/integration_settings/key_definition.rb +29 -0
- data/lib/collavre/integration_settings/registry.rb +55 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- 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:
|
|
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:
|
|
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
|
-
|
|
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?
|
|
@@ -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
|
-
|
|
16
|
-
|
|
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 =
|
|
26
|
-
main_topic_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
|
-
|
|
221
|
-
|
|
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 (
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
+
})
|