collavre 0.20.3 → 0.22.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 (163) 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 +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -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', 'scrollRow']
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() {
@@ -167,8 +196,14 @@ export default class extends Controller {
167
196
  }
168
197
  if (data.typing) {
169
198
  const { id, name } = data.typing
199
+ const isNewTyper = !(id in this.typingUsers)
200
+ // The user always wants to see their OWN typing indicator the moment it
201
+ // appears — they just started typing. Stick-to-end (which pauses while the
202
+ // user is scrolled back looking at badges) must not suppress that, so force
203
+ // the scroll for the local user's first typing frame.
204
+ const isSelf = String(id) === String(this.currentUserId)
170
205
  this.typingUsers[id] = name
171
- this.renderTypingIndicator()
206
+ this.renderTypingIndicator({ newItem: isNewTyper, force: isNewTyper && isSelf })
172
207
  clearTimeout(this.typingTimers[id])
173
208
  this.typingTimers[id] = setTimeout(() => {
174
209
  delete this.typingUsers[id]
@@ -201,12 +236,16 @@ export default class extends Controller {
201
236
  this.loadParticipants({ closeOnForbidden: shareChange.has_access === false })
202
237
  return
203
238
  }
239
+ if (data.channel_chips) {
240
+ this.refreshChannelChips(data.channel_chips.topic_id)
241
+ }
204
242
  if (data.agent_status) {
205
243
  const { id, name, status, task_id, creative_id: agentCreativeId } = data.agent_status
206
244
  // Only show typing indicator if agent is working on this specific creative
207
245
  if (agentCreativeId && String(agentCreativeId) !== String(this.creativeId)) {
208
246
  return
209
247
  }
248
+ const isNewAgent = (status === 'thinking' || status === 'streaming') && !(id in this.typingUsers)
210
249
  if (status === 'thinking' || status === 'streaming') {
211
250
  this.typingUsers[id] = name
212
251
  if (!this.activeAgentTasks) this.activeAgentTasks = {}
@@ -230,7 +269,7 @@ export default class extends Controller {
230
269
  }
231
270
  }
232
271
  this.syncGlobalAgentTasks()
233
- this.renderTypingIndicator()
272
+ this.renderTypingIndicator({ newItem: isNewAgent })
234
273
  }
235
274
  }
236
275
 
@@ -314,8 +353,16 @@ export default class extends Controller {
314
353
  }
315
354
 
316
355
 
317
- renderTypingIndicator() {
356
+ renderTypingIndicator({ newItem = false, force = false } = {}) {
318
357
  if (!this.hasTypingIndicatorTarget) return
358
+
359
+ // Capture stick-to-end BEFORE mutating the DOM: only auto-scroll a newly
360
+ // added item into view when the user was already parked at the right edge,
361
+ // so we never yank them away from a chip they scrolled back to look at.
362
+ // `force` overrides that guard for cases where the scroll is always wanted
363
+ // (e.g. the local user's own typing indicator first appearing).
364
+ const stickToEnd = force || (newItem && this.isScrollRowAtEnd())
365
+
319
366
  this.typingIndicatorTarget.innerHTML = ''
320
367
 
321
368
  if (this.manualTypingMessage) {
@@ -381,6 +428,33 @@ export default class extends Controller {
381
428
  const text = document.createElement('span')
382
429
  text.textContent = `${names.join(', ')} ...`
383
430
  this.typingIndicatorTarget.appendChild(text)
431
+
432
+ if (stickToEnd) this.scrollRowToEnd()
433
+ }
434
+
435
+ // Distance (px) from the right edge still counted as "at the end". A small
436
+ // slack absorbs sub-pixel rounding and momentum scroll so stick-to-end stays
437
+ // engaged when the user is effectively, but not exactly, at the edge.
438
+ static STICK_TO_END_THRESHOLD = 24
439
+
440
+ get scrollRowElement() {
441
+ return this.hasScrollRowTarget ? this.scrollRowTarget : null
442
+ }
443
+
444
+ // True when the horizontal scroll row is at (or near) its right edge — i.e.
445
+ // the user is looking at the newest items rather than scrolled back. Returns
446
+ // true when there is no scroll row or no overflow (nothing to yank away from).
447
+ isScrollRowAtEnd() {
448
+ const el = this.scrollRowElement
449
+ if (!el) return true
450
+ const threshold = this.constructor.STICK_TO_END_THRESHOLD
451
+ return el.scrollLeft + el.clientWidth >= el.scrollWidth - threshold
452
+ }
453
+
454
+ scrollRowToEnd() {
455
+ const el = this.scrollRowElement
456
+ if (!el) return
457
+ el.scrollLeft = el.scrollWidth
384
458
  }
385
459
 
386
460
  syncGlobalAgentTasks() {
@@ -417,6 +491,65 @@ export default class extends Controller {
417
491
  .catch((err) => console.warn('[presence] cancel agent task failed:', err))
418
492
  }
419
493
 
494
+ clearChannelChips() {
495
+ const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
496
+ if (!target) return
497
+ target.innerHTML = ''
498
+ delete target.dataset.topicId
499
+ }
500
+
501
+ refreshChannelChips(topicId) {
502
+ const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
503
+ if (!target) return
504
+ if (!this.creativeId) return
505
+ if (!topicId) {
506
+ this.clearChannelChips()
507
+ return
508
+ }
509
+
510
+ // Source of truth for "what the user is looking at" is the topics
511
+ // controller's currentTopicId, NOT the chip container's data-topic-id:
512
+ // - On initial popup open the container is empty (no data-topic-id),
513
+ // so a stray broadcast for any topic in the same creative used to
514
+ // paint chips for the wrong topic.
515
+ // - After the user switches topics the container's data-topic-id is
516
+ // stale until something repaints it, blocking legit updates.
517
+ const topicsCtrl = this.application.getControllerForElementAndIdentifier(
518
+ this.element, 'comments--topics'
519
+ )
520
+ const activeTopicId = topicsCtrl?.currentTopicId || ''
521
+ if (String(activeTopicId) !== String(topicId)) return
522
+
523
+ fetch(`/creatives/${this.creativeId}/topics/${topicId}/channel_chips`, {
524
+ headers: { Accept: 'text/html' },
525
+ credentials: 'same-origin',
526
+ })
527
+ .then((r) => (r.ok ? r.text() : null))
528
+ .then((html) => {
529
+ if (!html) return
530
+ // A newly attached channel (e.g. a fresh PR/Preview badge) is a "new
531
+ // item" in the scroll row. Count chips before/after and scroll the new
532
+ // one into view, but only when the user was already at the right edge.
533
+ const row = this.scrollRowElement
534
+ const prevChipCount = row ? row.querySelectorAll('.channel-chip').length : 0
535
+ const wasAtEnd = this.isScrollRowAtEnd()
536
+ target.outerHTML = html
537
+ const newChipCount = row ? row.querySelectorAll('.channel-chip').length : 0
538
+ if (wasAtEnd && newChipCount > prevChipCount) this.scrollRowToEnd()
539
+ })
540
+ .catch((err) => console.warn('[presence] refresh channel chips failed:', err))
541
+ }
542
+
543
+ detachChannel(event) {
544
+ const btn = event.currentTarget
545
+ const id = btn.dataset.channelId
546
+ if (!id) return
547
+ csrfFetch(`/channels/${id}`, {
548
+ method: 'DELETE',
549
+ headers: { Accept: 'application/json' },
550
+ }).catch((err) => console.warn('[presence] detach channel failed:', err))
551
+ }
552
+
420
553
  clearTypingTimers() {
421
554
  Object.values(this.typingTimers).forEach((timer) => clearTimeout(timer))
422
555
  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
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
8
+ renderCreativeTree: jest.fn(),
9
+ dispatchCreativeTreeUpdated: jest.fn(),
10
+ applyRowProperties: jest.fn(),
11
+ }))
12
+
13
+ jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
14
+ parseEmojis: jest.fn(() => ['✨']),
15
+ }))
16
+
17
+ const { Application } = await import('@hotwired/stimulus')
18
+ const TreeController = (await import('../tree_controller')).default
19
+
20
+ const TRANSIENT_RETRY_DELAYS = [200, 600]
21
+
22
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
23
+
24
+ const collectRetryDelays = (spy) =>
25
+ spy.mock.calls
26
+ .map((args) => args[1])
27
+ .filter((delay) => TRANSIENT_RETRY_DELAYS.includes(delay))
28
+
29
+ const installController = () => {
30
+ const container = document.createElement('div')
31
+ container.setAttribute('data-controller', 'creatives--tree')
32
+ container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
33
+ document.body.appendChild(container)
34
+
35
+ const application = Application.start()
36
+ application.register('creatives--tree', TreeController)
37
+
38
+ return { container, application }
39
+ }
40
+
41
+ describe('CreativesTreeController retry on transient network errors', () => {
42
+ let originalFetch
43
+ let setTimeoutSpy
44
+
45
+ beforeEach(() => {
46
+ originalFetch = global.fetch
47
+ setTimeoutSpy = jest.spyOn(global, 'setTimeout')
48
+ })
49
+
50
+ afterEach(() => {
51
+ setTimeoutSpy.mockRestore()
52
+ global.fetch = originalFetch
53
+ document.body.innerHTML = ''
54
+ jest.restoreAllMocks()
55
+ })
56
+
57
+ test('schedules 200ms then 600ms backoff retry on TypeError "Failed to fetch"', async () => {
58
+ global.fetch = jest
59
+ .fn()
60
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
61
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
62
+ .mockResolvedValueOnce({
63
+ ok: true,
64
+ json: async () => ({ creatives: [] }),
65
+ })
66
+
67
+ const { application } = installController()
68
+ // Allow time for the 200ms + 600ms retries to fire
69
+ await new Promise((resolve) => setTimeout(resolve, 1000))
70
+
71
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
72
+
73
+ application.stop()
74
+ })
75
+
76
+ test('does NOT schedule retry on HTTP error responses', async () => {
77
+ jest.spyOn(console, 'error').mockImplementation(() => {})
78
+ global.fetch = jest.fn().mockResolvedValue({
79
+ ok: false,
80
+ status: 500,
81
+ json: async () => ({}),
82
+ })
83
+
84
+ const { application } = installController()
85
+ await flush()
86
+ await flush()
87
+ await flush()
88
+
89
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
90
+
91
+ application.stop()
92
+ })
93
+
94
+ test('does NOT schedule retry on AbortError', async () => {
95
+ const abortErr = new Error('aborted')
96
+ abortErr.name = 'AbortError'
97
+ global.fetch = jest.fn().mockRejectedValue(abortErr)
98
+
99
+ const { application } = installController()
100
+ await flush()
101
+ await flush()
102
+
103
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
104
+
105
+ application.stop()
106
+ })
107
+
108
+ test('gives up after exactly 2 retries on persistent transient errors', async () => {
109
+ jest.spyOn(console, 'error').mockImplementation(() => {})
110
+ global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
111
+
112
+ const { application } = installController()
113
+ // Allow time for both retries to complete (200 + 600 = 800ms)
114
+ await new Promise((resolve) => setTimeout(resolve, 1500))
115
+
116
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
117
+
118
+ application.stop()
119
+ })
120
+ })
@@ -24,20 +24,41 @@ export default class extends Controller {
24
24
  }
25
25
  }
26
26
 
27
- document.addEventListener('creative-editing:start', this.handleEditStart)
28
- document.addEventListener('creative-editing:stop', this.handleEditStop)
27
+ // Defer ActionCable subscribe until tree finishes loading. Opening the
28
+ // WebSocket while the tree fetch is in flight triggers ERR_NETWORK_CHANGED
29
+ // in Chromium because the browser tears down the in-flight HTTP connection.
30
+ // Exception: Turbo cache restores leave data-loaded="true" so tree_controller
31
+ // skips load() and never dispatches creative-tree:updated — subscribe now.
32
+ const subscribeForRoot = () => {
33
+ if (this.rootIdValue > 0) {
34
+ this.subscribe()
35
+ } else {
36
+ this.inferAndSubscribe()
37
+ }
38
+ }
29
39
 
30
- if (this.rootIdValue > 0) {
31
- this.subscribe()
40
+ if (this.element.dataset.loaded === 'true') {
41
+ subscribeForRoot()
32
42
  } else {
33
- // Top-level /creatives page — try to infer root from tree rows
34
- this.inferAndSubscribe()
43
+ this._handleTreeUpdated = () => {
44
+ this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
45
+ this._handleTreeUpdated = null
46
+ subscribeForRoot()
47
+ }
48
+ this.element.addEventListener('creative-tree:updated', this._handleTreeUpdated)
35
49
  }
50
+
51
+ document.addEventListener('creative-editing:start', this.handleEditStart)
52
+ document.addEventListener('creative-editing:stop', this.handleEditStop)
36
53
  }
37
54
 
38
55
  disconnect() {
39
56
  document.removeEventListener('creative-editing:start', this.handleEditStart)
40
57
  document.removeEventListener('creative-editing:stop', this.handleEditStop)
58
+ if (this._handleTreeUpdated) {
59
+ this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
60
+ this._handleTreeUpdated = null
61
+ }
41
62
 
42
63
  if (this.subscription) {
43
64
  this.subscription.cleanup()
@@ -50,9 +71,9 @@ export default class extends Controller {
50
71
  this.subscription.cleanup()
51
72
  this.subscription = null
52
73
  }
53
- if (this.rootIdValue > 0) {
54
- this.subscribe()
55
- }
74
+ // Skip re-subscribing here — connect() defers the first subscribe until
75
+ // creative-tree:updated fires so the WebSocket handshake doesn't race
76
+ // the tree's HTTP fetch.
56
77
  }
57
78
 
58
79
  inferAndSubscribe() {
@@ -2,6 +2,8 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
3
3
  import { parseEmojis } from '../../utils/emoji_parser'
4
4
 
5
+ const TREE_RETRY_DELAYS_MS = [200, 600]
6
+
5
7
  export default class extends Controller {
6
8
  static values = {
7
9
  url: String,
@@ -49,6 +51,10 @@ export default class extends Controller {
49
51
  this.abortController.abort()
50
52
  this.abortController = null
51
53
  }
54
+ if (this._retryTimer) {
55
+ clearTimeout(this._retryTimer)
56
+ this._retryTimer = null
57
+ }
52
58
  window.removeEventListener('resize', this.handleResize)
53
59
  this.element.removeEventListener('creative-tree:updated', this.handleTreeUpdated)
54
60
  document.removeEventListener('creative-editing:start', this._handleEditStart)
@@ -138,8 +144,12 @@ export default class extends Controller {
138
144
  this.abortController.abort()
139
145
  }
140
146
  this.abortController = new AbortController()
147
+ this._retryCount = 0
141
148
  this.showLoadingIndicator()
149
+ this._fetchTree()
150
+ }
142
151
 
152
+ _fetchTree() {
143
153
  fetch(this.urlValue, {
144
154
  headers: { Accept: 'application/json' },
145
155
  signal: this.abortController.signal,
@@ -154,12 +164,25 @@ export default class extends Controller {
154
164
  })
155
165
  .catch((error) => {
156
166
  if (error.name === 'AbortError') return
167
+ // Transient network failures (ERR_NETWORK_CHANGED, offline blips, VPN
168
+ // toggles) surface as TypeError "Failed to fetch". Retry briefly so a
169
+ // momentary network event doesn't leave the user with an empty tree.
170
+ if (this._isTransientNetworkError(error) && this._retryCount < TREE_RETRY_DELAYS_MS.length) {
171
+ const delay = TREE_RETRY_DELAYS_MS[this._retryCount]
172
+ this._retryCount += 1
173
+ this._retryTimer = setTimeout(() => this._fetchTree(), delay)
174
+ return
175
+ }
157
176
  console.error(error)
158
177
  this.hideLoadingIndicator()
159
178
  this.showEmptyState()
160
179
  })
161
180
  }
162
181
 
182
+ _isTransientNetworkError(error) {
183
+ return error instanceof TypeError && /fetch|network/i.test(error.message || '')
184
+ }
185
+
163
186
  renderData(data) {
164
187
  const nodes = Array.isArray(data?.creatives) ? data.creatives : []
165
188
 
@@ -33,6 +33,7 @@ import CommentBadgeController from "./comment_badge_controller"
33
33
  import ShareModalController from "./share_modal_controller"
34
34
  import ImageLightboxController from "./image_lightbox_controller"
35
35
  import SearchPopupController from "./search_popup_controller"
36
+ import LandingVideoController from "./landing_video_controller"
36
37
 
37
38
  // Export all controllers
38
39
  export {
@@ -67,7 +68,8 @@ export {
67
68
  ShareModalController,
68
69
  ImageLightboxController,
69
70
  SearchPopupController,
70
- CommentBadgeController
71
+ CommentBadgeController,
72
+ LandingVideoController
71
73
  }
72
74
 
73
75
  // Registration function for use with a Stimulus application
@@ -105,4 +107,5 @@ export function registerControllers(application) {
105
107
  application.register("image-lightbox", ImageLightboxController)
106
108
  application.register("search-popup", SearchPopupController)
107
109
  application.register("comment-badge", CommentBadgeController)
110
+ application.register("landing-video", LandingVideoController)
108
111
  }
@@ -0,0 +1,53 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["video", "progressBar", "progressFill", "toggle", "icon"]
5
+
6
+ connect() {
7
+ this._raf = null
8
+ this._updateProgress = this._updateProgress.bind(this)
9
+ this.videoTarget.addEventListener("play", () => this._startProgress())
10
+ this.videoTarget.addEventListener("pause", () => this._stopProgress())
11
+ this.videoTarget.addEventListener("ended", () => this._stopProgress())
12
+ if (!this.videoTarget.paused) this._startProgress()
13
+ }
14
+
15
+ disconnect() {
16
+ this._stopProgress()
17
+ }
18
+
19
+ togglePlay() {
20
+ if (this.videoTarget.paused) {
21
+ this.videoTarget.play()
22
+ this.iconTarget.textContent = "❚❚"
23
+ } else {
24
+ this.videoTarget.pause()
25
+ this.iconTarget.textContent = "▶"
26
+ }
27
+ }
28
+
29
+ _startProgress() {
30
+ this._stopProgress()
31
+ this._tick()
32
+ }
33
+
34
+ _stopProgress() {
35
+ if (this._raf) {
36
+ cancelAnimationFrame(this._raf)
37
+ this._raf = null
38
+ }
39
+ }
40
+
41
+ _tick() {
42
+ this._updateProgress()
43
+ this._raf = requestAnimationFrame(() => this._tick())
44
+ }
45
+
46
+ _updateProgress() {
47
+ const v = this.videoTarget
48
+ if (v.duration) {
49
+ const pct = (v.currentTime / v.duration) * 100
50
+ this.progressFillTarget.style.width = `${pct}%`
51
+ }
52
+ }
53
+ }