collavre 0.8.2 → 0.9.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/activity_logs.css +1 -1
  3. data/app/assets/stylesheets/collavre/comment_versions.css +10 -6
  4. data/app/assets/stylesheets/collavre/comments_popup.css +60 -2
  5. data/app/assets/stylesheets/collavre/creatives.css +7 -4
  6. data/app/assets/stylesheets/collavre/dark_mode.css +25 -5
  7. data/app/controllers/collavre/comments_controller.rb +2 -1
  8. data/app/controllers/collavre/topics_controller.rb +80 -3
  9. data/app/controllers/concerns/collavre/comments/approval_actions.rb +5 -0
  10. data/app/controllers/concerns/collavre/comments/batch_operations.rb +32 -0
  11. data/app/javascript/controllers/comment_version_controller.js +5 -1
  12. data/app/javascript/controllers/comments/contexts_controller.js +118 -3
  13. data/app/javascript/controllers/comments/list_controller.js +67 -1
  14. data/app/javascript/controllers/comments/popup_controller.js +143 -10
  15. data/app/javascript/controllers/comments/presence_controller.js +22 -0
  16. data/app/javascript/controllers/comments/topics_controller.js +105 -6
  17. data/app/javascript/controllers/creatives/expansion_controller.js +8 -0
  18. data/app/javascript/controllers/creatives/select_mode_controller.js +4 -0
  19. data/app/javascript/creatives/drag_drop/event_handlers.js +5 -5
  20. data/app/javascript/modules/creative_row_editor.js +13 -0
  21. data/app/jobs/collavre/compress_job.rb +24 -25
  22. data/app/jobs/collavre/merge_comments_job.rb +79 -0
  23. data/app/models/collavre/topic.rb +29 -0
  24. data/app/models/concerns/collavre/ai_agent_resolvable.rb +42 -0
  25. data/app/services/collavre/comments/topic_command.rb +27 -13
  26. data/app/views/collavre/comments/_comments_popup.html.erb +5 -1
  27. data/config/locales/comments.en.yml +11 -1
  28. data/config/locales/comments.ko.yml +11 -1
  29. data/config/routes.rb +2 -0
  30. data/lib/collavre/version.rb +1 -1
  31. metadata +3 -1
@@ -513,14 +513,19 @@ export default class extends Controller {
513
513
  if (this.selection.size === 0) return
514
514
 
515
515
  const count = this.selection.size
516
+ const total = this.listTarget.querySelectorAll('.comment-select-checkbox').length
517
+ const allSelected = count === total
516
518
  const i18n = (key, fallback) => this.element.dataset[key] || fallback
519
+ const selectAllLabel = i18n('selectionSelectAllText', 'All')
517
520
 
518
521
  const bar = document.createElement('div')
519
522
  bar.className = 'selection-action-bar'
520
523
  bar.innerHTML = `
521
524
  <div class="selection-action-bar-main">
522
- <span class="selection-action-bar-count">${i18n('selectionCountText', '{count}개 선택').replace('{count}', count)}</span>
525
+ <label class="selection-action-bar-select-all"><input type="checkbox" class="selection-action-bar-select-all-checkbox"${allSelected ? ' checked' : ''}> ${selectAllLabel}</label>
526
+ <span class="selection-action-bar-count">${i18n('selectionCountText', '{count}/{total}개 선택').replace('{count}', count).replace('{total}', total)}</span>
523
527
  <button type="button" class="selection-action-bar-btn selection-action-delete" title="${i18n('selectionDeleteText', 'Delete')}">🗑 ${i18n('selectionDeleteText', 'Delete')}</button>
528
+ <button type="button" class="selection-action-bar-btn selection-action-merge" title="${i18n('selectionMergeText', 'Merge')}"${count < 2 ? ' disabled' : ''}>🔗 ${i18n('selectionMergeText', 'Merge')}</button>
524
529
  <button type="button" class="selection-action-bar-btn selection-action-move" title="${i18n('selectionMoveText', 'Move')}">📤 ${i18n('selectionMoveText', 'Move')}</button>
525
530
  <button type="button" class="selection-action-bar-btn selection-action-topic" title="${i18n('selectionTopicMoveText', 'Move to topic')}">🏷 ${i18n('selectionTopicMoveText', 'Move to topic')}</button>
526
531
  <button type="button" class="selection-action-bar-close" title="${i18n('selectionCloseText', 'Cancel')}">✕</button>
@@ -530,7 +535,40 @@ export default class extends Controller {
530
535
  </div>
531
536
  `
532
537
 
538
+ // Set indeterminate state if partially selected
539
+ const selectAllCheckbox = bar.querySelector('.selection-action-bar-select-all-checkbox')
540
+ if (count > 0 && !allSelected) {
541
+ selectAllCheckbox.indeterminate = true
542
+ }
543
+
544
+ // Select All toggle handler
545
+ selectAllCheckbox.addEventListener('change', () => {
546
+ const shouldSelect = selectAllCheckbox.checked
547
+ this.listTarget.querySelectorAll('.comment-select-checkbox').forEach((checkbox) => {
548
+ const commentId = checkbox.value
549
+ const item = checkbox.closest('.comment-item')
550
+ if (shouldSelect && !checkbox.checked) {
551
+ checkbox.checked = true
552
+ this.selection.add(commentId)
553
+ if (item) {
554
+ item.classList.add('selected-for-move')
555
+ item.setAttribute('draggable', 'true')
556
+ }
557
+ } else if (!shouldSelect && checkbox.checked) {
558
+ checkbox.checked = false
559
+ this.selection.delete(commentId)
560
+ if (item) {
561
+ item.classList.remove('selected-for-move')
562
+ item.removeAttribute('draggable')
563
+ }
564
+ }
565
+ })
566
+ this.notifySelectionChange()
567
+ this.updateSelectionActionBar()
568
+ })
569
+
533
570
  bar.querySelector('.selection-action-delete').addEventListener('click', (e) => { e.stopPropagation(); this.deleteSelectedComments() })
571
+ bar.querySelector('.selection-action-merge').addEventListener('click', (e) => { e.stopPropagation(); this.mergeSelectedComments() })
534
572
  bar.querySelector('.selection-action-move').addEventListener('click', (e) => this.openMoveModal(e))
535
573
  bar.querySelector('.selection-action-topic').addEventListener('click', (e) => this.openTopicSearchPopup(e))
536
574
  bar.querySelector('.selection-action-bar-close').addEventListener('click', () => this.clearSelection())
@@ -575,6 +613,34 @@ export default class extends Controller {
575
613
  }
576
614
  }
577
615
 
616
+ async mergeSelectedComments() {
617
+ if (this.selection.size < 2) return
618
+ const confirmText = this.element.dataset.mergeConfirmText || 'Merge the selected messages into one?'
619
+ if (!confirm(confirmText)) return
620
+
621
+ const commentIds = Array.from(this.selection)
622
+ try {
623
+ const response = await fetch(`/creatives/${this.creativeId}/comments/merge`, {
624
+ method: 'POST',
625
+ headers: {
626
+ 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
627
+ 'Content-Type': 'application/json',
628
+ },
629
+ body: JSON.stringify({ comment_ids: commentIds }),
630
+ })
631
+ if (response.ok || response.status === 202) {
632
+ this.clearSelection()
633
+ // Job is async — the merged comment will update via broadcast
634
+ } else {
635
+ const data = await response.json().catch(() => ({}))
636
+ alert(data.error || 'Failed to merge comments')
637
+ }
638
+ } catch (error) {
639
+ console.error('Error merging comments:', error)
640
+ alert('Failed to merge comments')
641
+ }
642
+ }
643
+
578
644
  openTopicSearchPopup(event) {
579
645
  if (this.selection.size === 0) return
580
646
 
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
2
2
 
3
3
  const SIZE_STORAGE_KEY = 'commentsPopupSize'
4
4
  const CREATIVE_CLICK_EVENT = 'creative-comments-click'
5
+ const CREATIVE_DESTROYED_EVENT = 'creative-destroyed'
5
6
 
6
7
  export default class extends Controller {
7
8
  static targets = [
@@ -24,6 +25,7 @@ export default class extends Controller {
24
25
  this.openFromUrlObserver = null
25
26
  this.openFromUrlTimeout = null
26
27
  this.handleCreativeClick = this.handleCreativeClick.bind(this)
28
+ this.handleCreativeDestroyed = this.handleCreativeDestroyed.bind(this)
27
29
  this.handleTouchStart = this.handleTouchStart.bind(this)
28
30
  this.handleTouchEnd = this.handleTouchEnd.bind(this)
29
31
  this.handleResizeMove = this.handleResizeMove.bind(this)
@@ -37,6 +39,7 @@ export default class extends Controller {
37
39
  this.handlePopupWheel = this.handlePopupWheel.bind(this)
38
40
 
39
41
  document.addEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
42
+ document.addEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
40
43
  this.element.addEventListener('wheel', this.handlePopupWheel, { passive: false })
41
44
  window.addEventListener('online', this.handleOnline)
42
45
  window.addEventListener('focus', this.handleWindowFocus)
@@ -92,6 +95,7 @@ export default class extends Controller {
92
95
  disconnect() {
93
96
  this.clearPendingOpenFromUrl()
94
97
  document.removeEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
98
+ document.removeEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
95
99
  this.element.removeEventListener('wheel', this.handlePopupWheel)
96
100
  window.removeEventListener('online', this.handleOnline)
97
101
  window.removeEventListener('focus', this.handleWindowFocus)
@@ -147,6 +151,14 @@ export default class extends Controller {
147
151
  this.open(button, { creativeId })
148
152
  }
149
153
 
154
+ handleCreativeDestroyed(event) {
155
+ const destroyedIds = event.detail?.creativeIds || []
156
+ if (this.element.style.display !== 'flex') return
157
+ if (destroyedIds.includes(this.element.dataset.creativeId)) {
158
+ this.close()
159
+ }
160
+ }
161
+
150
162
  async open(button, { creativeId, highlightId } = {}) {
151
163
  this.currentButton = button
152
164
  const resolvedCreativeId = creativeId || button?.dataset.creativeId
@@ -157,6 +169,8 @@ export default class extends Controller {
157
169
  this.element.dataset.canComment = canComment ? 'true' : 'false'
158
170
  this.titleTarget.textContent = snippet
159
171
 
172
+ this._markChatActiveRow(resolvedCreativeId)
173
+
160
174
  this.prepareSize()
161
175
 
162
176
  this.showPopup()
@@ -184,6 +198,8 @@ export default class extends Controller {
184
198
  this.element.dataset.canComment = canComment ? 'true' : 'false'
185
199
  this.titleTarget.textContent = snippet
186
200
 
201
+ this._markChatActiveRow(resolvedCreativeId)
202
+
187
203
  this.showPopup()
188
204
 
189
205
  await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment })
@@ -268,6 +284,8 @@ export default class extends Controller {
268
284
  }
269
285
  }))
270
286
 
287
+ this._clearChatActiveRow()
288
+
271
289
  this.element.style.display = 'none'
272
290
  this.element.classList.remove('open')
273
291
  this.element.style.width = ''
@@ -313,14 +331,27 @@ export default class extends Controller {
313
331
  updatePosition() {
314
332
  if (this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
315
333
  const rect = this.currentButton.getBoundingClientRect()
334
+ const popupWidth = this.element.offsetWidth
335
+ const popupHeight = this.element.offsetHeight
336
+ const gap = 8
337
+
316
338
  let top = rect.bottom + 4
317
- const bottom = top + this.element.offsetHeight
339
+ const bottom = top + popupHeight
318
340
  if (bottom > window.innerHeight) {
319
- top = Math.max(4, window.innerHeight - this.element.offsetHeight - 4)
341
+ top = Math.max(4, window.innerHeight - popupHeight - 4)
320
342
  }
321
343
  this.element.style.top = `${top}px`
322
- this.element.style.right = `${window.innerWidth - rect.right + 24}px`
323
- this.element.style.left = ''
344
+
345
+ // If there's enough space to the right of the button, align popup to the right
346
+ // so the creative list on the left remains visible
347
+ const spaceRight = window.innerWidth - rect.right - gap
348
+ if (spaceRight >= popupWidth) {
349
+ this.element.style.left = `${rect.right + gap}px`
350
+ this.element.style.right = ''
351
+ } else {
352
+ this.element.style.right = `${window.innerWidth - rect.right + 24}px`
353
+ this.element.style.left = ''
354
+ }
324
355
  }
325
356
 
326
357
  startResize(event, direction) {
@@ -440,6 +471,38 @@ export default class extends Controller {
440
471
  handlePopupWheel(event) {
441
472
  if (this.isFullscreen()) return // fullscreen already blocks body scroll via CSS
442
473
 
474
+ // Don't interfere with scroll inside overlays (e.g., share modal)
475
+ if (event.target.closest('#share-creative-modal')) return
476
+
477
+ // Allow scroll inside any independently scrollable child element.
478
+ // Walk up from the event target to find any element (other than the main
479
+ // comments list, which is handled below) that can scroll on its own.
480
+ const { element: scrollableChild, axis } = this._findScrollableAncestor(event.target, event)
481
+ if (scrollableChild) {
482
+ if (axis === 'x') {
483
+ // Horizontal scroll — check left/right boundaries
484
+ const { scrollLeft, scrollWidth, clientWidth } = scrollableChild
485
+ const isScrollingRight = event.deltaX > 0
486
+ const atLeft = scrollLeft <= 0
487
+ const atRight = scrollLeft + clientWidth >= scrollWidth - 1
488
+
489
+ if ((isScrollingRight && atRight) || (!isScrollingRight && atLeft)) {
490
+ event.preventDefault()
491
+ }
492
+ } else {
493
+ // Vertical scroll — check top/bottom boundaries
494
+ const { scrollTop, scrollHeight, clientHeight } = scrollableChild
495
+ const isScrollingDown = event.deltaY > 0
496
+ const atTop = scrollTop <= 0
497
+ const atBottom = scrollTop + clientHeight >= scrollHeight - 1
498
+
499
+ if ((isScrollingDown && atBottom) || (!isScrollingDown && atTop)) {
500
+ event.preventDefault()
501
+ }
502
+ }
503
+ return
504
+ }
505
+
443
506
  if (!this.hasListTarget) {
444
507
  event.preventDefault()
445
508
  return
@@ -622,7 +685,7 @@ export default class extends Controller {
622
685
  if (targetButton) {
623
686
  this.currentButton = targetButton
624
687
  const btnRect = targetButton.getBoundingClientRect()
625
- const rightPx = window.innerWidth - btnRect.right + 24
688
+ const gap = 8
626
689
 
627
690
  animWidth = parseFloat(finalWidth) || 420
628
691
  animHeight = parseFloat(finalHeight) || 640
@@ -635,10 +698,20 @@ export default class extends Controller {
635
698
  }
636
699
 
637
700
  finalTop = `${top}px`
638
- finalRight = `${rightPx}px`
639
701
 
640
- animTop = top
641
- animLeft = window.innerWidth - rightPx - animWidth
702
+ // Right-align if enough space to the right of the button
703
+ const spaceRight = window.innerWidth - btnRect.right - gap
704
+ if (spaceRight >= animWidth) {
705
+ this._exitToRight = true
706
+ animLeft = btnRect.right + gap
707
+ animTop = top
708
+ } else {
709
+ this._exitToRight = false
710
+ const rightPx = window.innerWidth - btnRect.right + 24
711
+ finalRight = `${rightPx}px`
712
+ animTop = top
713
+ animLeft = window.innerWidth - rightPx - animWidth
714
+ }
642
715
  } else if (savedStyles && Object.values(savedStyles).some(v => v)) {
643
716
  // Fallback to saved styles (already viewport-relative since popup is fixed)
644
717
  const rightVal = parseFloat(savedStyles.right) || 32
@@ -692,10 +765,15 @@ export default class extends Controller {
692
765
 
693
766
  if (targetButton) {
694
767
  el.style.top = finalTop
695
- el.style.right = finalRight
696
- el.style.left = ''
697
768
  el.style.width = finalWidth
698
769
  el.style.height = finalHeight
770
+ if (this._exitToRight) {
771
+ el.style.left = `${animLeft}px`
772
+ el.style.right = ''
773
+ } else {
774
+ el.style.right = finalRight
775
+ el.style.left = ''
776
+ }
699
777
  } else if (savedStyles) {
700
778
  el.style.top = ''
701
779
  el.style.left = ''
@@ -855,4 +933,59 @@ export default class extends Controller {
855
933
  this.openFromUrlTimeout = null
856
934
  }
857
935
  }
936
+
937
+ _markChatActiveRow(creativeId) {
938
+ this._clearChatActiveRow()
939
+ if (!creativeId) return
940
+ const row = document.querySelector(`creative-tree-row[creative-id="${creativeId}"]`)
941
+ if (row) row.classList.add('chat-active')
942
+ }
943
+
944
+ _clearChatActiveRow() {
945
+ document.querySelectorAll('creative-tree-row.chat-active').forEach(el => {
946
+ el.classList.remove('chat-active')
947
+ })
948
+ }
949
+
950
+ // Walk up from the target element to find the nearest scrollable ancestor
951
+ // that is NOT the main comments list (which has its own scroll handling).
952
+ // Detects both vertical and horizontal scrollable elements.
953
+ // Returns { element, axis } or { element: null, axis: null }.
954
+ _findScrollableAncestor(target, event) {
955
+ let el = target
956
+ const listEl = this.hasListTarget ? this.listTarget : null
957
+ const dominantAxis = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'x' : 'y'
958
+
959
+ while (el && el !== this.element) {
960
+ // Skip the main comments list — it's handled separately
961
+ if (el === listEl) return { element: null, axis: null }
962
+
963
+ // Cheap size checks first to avoid expensive getComputedStyle calls
964
+ const hasOverflowY = el.scrollHeight > el.clientHeight
965
+ const hasOverflowX = el.scrollWidth > el.clientWidth
966
+
967
+ if (hasOverflowY || hasOverflowX) {
968
+ const style = getComputedStyle(el)
969
+
970
+ // Check dominant axis first for better matching
971
+ if (dominantAxis === 'x' && hasOverflowX) {
972
+ const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
973
+ if (scrollableX) return { element: el, axis: 'x' }
974
+ }
975
+
976
+ if (hasOverflowY) {
977
+ const scrollableY = style.overflowY === 'auto' || style.overflowY === 'scroll'
978
+ if (scrollableY) return { element: el, axis: 'y' }
979
+ }
980
+
981
+ if (dominantAxis !== 'x' && hasOverflowX) {
982
+ const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
983
+ if (scrollableX) return { element: el, axis: 'x' }
984
+ }
985
+ }
986
+
987
+ el = el.parentElement
988
+ }
989
+ return { element: null, axis: null }
990
+ }
858
991
  }
@@ -204,6 +204,28 @@ export default class extends Controller {
204
204
  if (user.email) img.dataset.email = user.email
205
205
  img.dataset.userId = user.id
206
206
  img.dataset.userName = user.name
207
+
208
+ // AI agents are draggable to topic tabs
209
+ if (user.ai_user) {
210
+ wrapper.draggable = true
211
+ wrapper.classList.add('ai-agent-draggable')
212
+ wrapper.dataset.agentId = user.id
213
+ wrapper.dataset.agentName = user.name
214
+ wrapper.dataset.agentAvatarUrl = user.avatar_url
215
+ wrapper.addEventListener('dragstart', (e) => {
216
+ e.dataTransfer.setData('application/x-agent-drop', JSON.stringify({
217
+ id: user.id,
218
+ name: user.name,
219
+ avatar_url: user.avatar_url
220
+ }))
221
+ e.dataTransfer.effectAllowed = 'copy'
222
+ wrapper.classList.add('dragging')
223
+ })
224
+ wrapper.addEventListener('dragend', () => {
225
+ wrapper.classList.remove('dragging')
226
+ })
227
+ }
228
+
207
229
  wrapper.appendChild(img)
208
230
 
209
231
  if (user.default_avatar) {
@@ -114,10 +114,13 @@ export default class extends Controller {
114
114
  // Ensure comparison handles string/number difference
115
115
  const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
116
116
  const draggable = canManage ? 'draggable="true"' : ''
117
+ const agentAvatar = topic.primary_agent?.avatar_url
118
+ ? `<img src="${this.escapeAttr(topic.primary_agent.avatar_url)}" class="topic-agent-avatar" alt="${this.escapeAttr(topic.primary_agent.name)}" title="${this.escapeAttr(topic.primary_agent.name)}">`
119
+ : ''
117
120
  html += `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
118
121
  data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
119
122
  data-id="${topic.id}">
120
- #${topic.name}`
123
+ ${agentAvatar}#${topic.name}`
121
124
 
122
125
  if (canManage) {
123
126
  html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
@@ -144,7 +147,8 @@ export default class extends Controller {
144
147
 
145
148
  // Add create button container (write permission is sufficient for topic creation)
146
149
  if (canCreateTopic) {
147
- html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer">
150
+ html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer"
151
+ data-action="dragover->comments--topics#handleAddButtonDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleAddButtonDrop">
148
152
  <button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>
149
153
  </span>`
150
154
  }
@@ -153,11 +157,13 @@ export default class extends Controller {
153
157
  }
154
158
 
155
159
  handleDragOver(event) {
156
- // Only accept comment drops
157
- if (!event.dataTransfer.types.includes('application/x-comment-ids')) return
160
+ // Accept comment drops or agent drops
161
+ const isComment = event.dataTransfer.types.includes('application/x-comment-ids')
162
+ const isAgent = event.dataTransfer.types.includes('application/x-agent-drop')
163
+ if (!isComment && !isAgent) return
158
164
 
159
165
  event.preventDefault()
160
- event.dataTransfer.dropEffect = 'move'
166
+ event.dataTransfer.dropEffect = isAgent ? 'copy' : 'move'
161
167
  event.currentTarget.classList.add('drag-over')
162
168
  }
163
169
 
@@ -169,6 +175,18 @@ export default class extends Controller {
169
175
  event.preventDefault()
170
176
  event.currentTarget.classList.remove('drag-over')
171
177
 
178
+ // Handle agent drop
179
+ const agentJson = event.dataTransfer.getData('application/x-agent-drop')
180
+ if (agentJson) {
181
+ const agent = JSON.parse(agentJson)
182
+ const targetTopicId = event.currentTarget.dataset.id
183
+ if (targetTopicId) {
184
+ await this.setTopicPrimaryAgent(targetTopicId, agent)
185
+ }
186
+ return
187
+ }
188
+
189
+ // Handle comment drop
172
190
  const commentIdsJson = event.dataTransfer.getData('application/x-comment-ids')
173
191
  if (!commentIdsJson) return
174
192
 
@@ -710,7 +728,14 @@ export default class extends Controller {
710
728
 
711
729
  this.topics = [...topics, data.topic]
712
730
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
713
- this.restoreSelection()
731
+
732
+ // Auto-select the new topic if created by the current user
733
+ const currentUserId = document.body.dataset.currentUserId
734
+ if (data.user_id && currentUserId && String(data.user_id) === String(currentUserId)) {
735
+ this.selectTopic(String(data.topic.id))
736
+ } else {
737
+ this.restoreSelection()
738
+ }
714
739
  }
715
740
 
716
741
  reorderTopicsFromServer(topicIds) {
@@ -760,4 +785,78 @@ export default class extends Controller {
760
785
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
761
786
  this.restoreSelection()
762
787
  }
788
+
789
+ handleAddButtonDragOver(event) {
790
+ if (!event.dataTransfer.types.includes('application/x-agent-drop')) return
791
+ event.preventDefault()
792
+ event.dataTransfer.dropEffect = 'copy'
793
+ event.currentTarget.classList.add('drag-over')
794
+ }
795
+
796
+ async handleAddButtonDrop(event) {
797
+ event.preventDefault()
798
+ event.currentTarget.classList.remove('drag-over')
799
+
800
+ const agentJson = event.dataTransfer.getData('application/x-agent-drop')
801
+ if (!agentJson) return
802
+
803
+ const agent = JSON.parse(agentJson)
804
+ await this.createTopicWithAgent(agent)
805
+ }
806
+
807
+ async setTopicPrimaryAgent(topicId, agent) {
808
+ if (!this.creativeId) return
809
+
810
+ try {
811
+ const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}/set_primary_agent`, {
812
+ method: 'PATCH',
813
+ headers: {
814
+ 'Content-Type': 'application/json',
815
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
816
+ },
817
+ body: JSON.stringify({ agent_id: agent.id })
818
+ })
819
+
820
+ if (!response.ok) {
821
+ const data = await response.json()
822
+ console.error('Failed to set primary agent:', data.error)
823
+ }
824
+ // Topic update comes via WebSocket broadcast
825
+ } catch (e) {
826
+ console.error('Error setting primary agent', e)
827
+ }
828
+ }
829
+
830
+ async createTopicWithAgent(agent) {
831
+ if (!this.creativeId) return
832
+
833
+ const topicName = `Talk to ${agent.name}`
834
+
835
+ try {
836
+ const response = await fetch(`/creatives/${this.creativeId}/topics`, {
837
+ method: 'POST',
838
+ headers: {
839
+ 'Content-Type': 'application/json',
840
+ 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
841
+ },
842
+ body: JSON.stringify({ topic: { name: topicName }, agent_id: agent.id })
843
+ })
844
+
845
+ if (response.ok) {
846
+ const topic = await response.json()
847
+ this.currentTopicId = topic.id
848
+ await this.loadTopics()
849
+ this.dispatch("change", { detail: { topicId: topic.id } })
850
+ } else {
851
+ console.error('Failed to create topic with agent')
852
+ }
853
+ } catch (e) {
854
+ console.error('Error creating topic with agent', e)
855
+ }
856
+ }
857
+
858
+ escapeAttr(str) {
859
+ if (!str) return ''
860
+ return str.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
861
+ }
763
862
  }
@@ -100,6 +100,7 @@ export default class extends Controller {
100
100
  const childrenDiv = this.childrenContainerFor(row)
101
101
  this.ensureLoaded(row, childrenDiv).then((hasChildren) => {
102
102
  if (!hasChildren || !childrenDiv) {
103
+ row.hasChildren = false
103
104
  this.collapseRow(row, { persist: false })
104
105
  return
105
106
  }
@@ -177,6 +178,13 @@ export default class extends Controller {
177
178
 
178
179
  syncInitialState(row) {
179
180
  const childrenDiv = this.childrenContainerFor(row)
181
+
182
+ // If the row claims it has children but no children container exists,
183
+ // correct the hasChildren flag so the chevron is hidden.
184
+ if (row.hasChildren && !childrenDiv) {
185
+ row.hasChildren = false
186
+ }
187
+
180
188
  const shouldExpand =
181
189
  this.allExpanded ||
182
190
  row.expanded ||
@@ -97,6 +97,10 @@ export default class extends Controller {
97
97
  )
98
98
  )
99
99
 
100
+ document.dispatchEvent(new CustomEvent('creative-destroyed', {
101
+ detail: { creativeIds: ids.map(String) }
102
+ }))
103
+
100
104
  ids.forEach((id) => {
101
105
  const tree = document.getElementById(`creative-${id}`)
102
106
  if (tree) tree.remove()
@@ -30,13 +30,13 @@ import { initIndicator, showLinkHover, hideLinkHover } from './indicator';
30
30
  const childZoneRatio = 0.3;
31
31
  const coordPrecision = 5;
32
32
 
33
- const TRANSFER_MIME_TYPE = 'application/x-plan42-creative';
34
- const DRAG_TOKEN_STORAGE_KEY = 'plan42.dragToken';
35
- const DROP_SIGNAL_STORAGE_KEY = 'plan42.dragDropSignal';
36
- const WINDOW_ID_SESSION_KEY = 'plan42.dragWindowId';
33
+ const TRANSFER_MIME_TYPE = 'application/x-collavre-creative';
34
+ const DRAG_TOKEN_STORAGE_KEY = 'collavre.dragToken';
35
+ const DROP_SIGNAL_STORAGE_KEY = 'collavre.dragDropSignal';
36
+ const WINDOW_ID_SESSION_KEY = 'collavre.dragWindowId';
37
37
  const INVALID_DROP_MESSAGE =
38
38
  'We could not verify that drop. Please refresh the page and try again.';
39
- const DROP_COMPLETED_EVENT = 'plan42:creative-drop-complete';
39
+ const DROP_COMPLETED_EVENT = 'collavre:creative-drop-complete';
40
40
 
41
41
  let cachedDragToken;
42
42
  let cachedWindowId;
@@ -1510,6 +1510,19 @@ export function initializeCreativeRowEditor() {
1510
1510
  }
1511
1511
 
1512
1512
  creativesApi.destroy(id, withChildren).then(() => {
1513
+ const destroyedIds = [String(id)];
1514
+ if (withChildren) {
1515
+ const childrenContainer = document.getElementById("creative-children-" + id);
1516
+ if (childrenContainer) {
1517
+ childrenContainer.querySelectorAll('creative-tree-row').forEach(row => {
1518
+ const cid = row.getAttribute('creative-id');
1519
+ if (cid) destroyedIds.push(cid);
1520
+ });
1521
+ }
1522
+ }
1523
+ document.dispatchEvent(new CustomEvent('creative-destroyed', {
1524
+ detail: { creativeIds: destroyedIds }
1525
+ }));
1513
1526
  const parentTree = parentId ? document.getElementById(`creative-${parentId}`) : null;
1514
1527
  const childrenTree = document.getElementById("creative-children-" + id)
1515
1528
  if (!withChildren && childrenTree && parentTree) {