collavre 0.1.1 → 0.2.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comments_popup.css +293 -8
  3. data/app/assets/stylesheets/collavre/mention_menu.css +26 -0
  4. data/app/assets/stylesheets/collavre/popup.css +7 -0
  5. data/app/assets/stylesheets/collavre/print.css +18 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +33 -0
  7. data/app/components/collavre/autocomplete_popup_component.html.erb +3 -0
  8. data/app/components/collavre/autocomplete_popup_component.rb +18 -0
  9. data/app/components/collavre/command_menu_component.rb +7 -0
  10. data/app/components/collavre/plans_timeline_component.html.erb +1 -1
  11. data/app/components/collavre/plans_timeline_component.rb +29 -32
  12. data/app/components/collavre/user_mention_menu_component.rb +4 -5
  13. data/app/controllers/collavre/comments_controller.rb +111 -10
  14. data/app/controllers/collavre/creatives_controller.rb +8 -0
  15. data/app/controllers/collavre/google_auth_controller.rb +5 -1
  16. data/app/controllers/collavre/plans_controller.rb +65 -9
  17. data/app/controllers/collavre/topics_controller.rb +42 -0
  18. data/app/controllers/collavre/users_controller.rb +4 -14
  19. data/app/errors/collavre/approval_pending_error.rb +54 -0
  20. data/app/errors/collavre/cancelled_error.rb +9 -0
  21. data/app/helpers/collavre/navigation_helper.rb +3 -1
  22. data/app/javascript/collavre.js +1 -0
  23. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +2 -1
  24. data/app/javascript/controllers/comments/form_controller.js +2 -1
  25. data/app/javascript/controllers/comments/list_controller.js +185 -2
  26. data/app/javascript/controllers/comments/popup_controller.js +95 -20
  27. data/app/javascript/controllers/comments/presence_controller.js +30 -1
  28. data/app/javascript/controllers/comments/topics_controller.js +314 -4
  29. data/app/javascript/modules/__tests__/creative_progress.test.js +50 -0
  30. data/app/javascript/modules/command_menu.js +116 -0
  31. data/app/javascript/modules/creative_progress.js +14 -0
  32. data/app/javascript/modules/creative_row_editor.js +104 -20
  33. data/app/javascript/modules/plans_timeline.js +15 -4
  34. data/app/javascript/modules/share_modal.js +3 -0
  35. data/app/jobs/collavre/ai_agent_job.rb +35 -21
  36. data/app/models/collavre/calendar_event.rb +7 -1
  37. data/app/models/collavre/comment.rb +35 -2
  38. data/app/models/collavre/creative.rb +1 -3
  39. data/app/models/collavre/mcp_tool.rb +4 -0
  40. data/app/models/collavre/plan.rb +23 -0
  41. data/app/models/collavre/topic.rb +12 -0
  42. data/app/models/collavre/user.rb +15 -1
  43. data/app/services/collavre/ai_agent_service.rb +174 -66
  44. data/app/services/collavre/ai_client.rb +31 -2
  45. data/app/services/collavre/comments/action_executor.rb +47 -1
  46. data/app/services/collavre/comments/calendar_command.rb +117 -18
  47. data/app/services/collavre/google_calendar_service.rb +38 -15
  48. data/app/services/collavre/markdown_importer.rb +47 -8
  49. data/app/services/collavre/mcp_service.rb +23 -10
  50. data/app/services/collavre/system_events/router.rb +50 -26
  51. data/app/services/collavre/tools/creative_create_service.rb +97 -0
  52. data/app/services/collavre/tools/creative_update_service.rb +116 -0
  53. data/app/views/collavre/comments/_comment.html.erb +2 -2
  54. data/app/views/collavre/comments/_comments_popup.html.erb +40 -6
  55. data/app/views/collavre/comments/fullscreen.html.erb +5 -0
  56. data/app/views/collavre/creatives/_inline_edit_form.html.erb +11 -3
  57. data/app/views/collavre/creatives/_integration_modals.html.erb +6 -0
  58. data/app/views/collavre/creatives/_integration_triggers.html.erb +8 -0
  59. data/app/views/collavre/creatives/_integrations_menu.html.erb +12 -0
  60. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +13 -1
  61. data/app/views/collavre/creatives/_share_button.html.erb +1 -1
  62. data/app/views/collavre/creatives/index.html.erb +22 -4
  63. data/app/views/collavre/users/edit_ai.html.erb +15 -0
  64. data/app/views/collavre/users/new_ai.html.erb +15 -0
  65. data/app/views/layouts/collavre/chat.html.erb +46 -0
  66. data/config/locales/ai_agent.en.yml +15 -0
  67. data/config/locales/ai_agent.ko.yml +15 -0
  68. data/config/locales/comments.en.yml +15 -3
  69. data/config/locales/comments.ko.yml +15 -3
  70. data/config/locales/creatives.en.yml +3 -31
  71. data/config/locales/creatives.ko.yml +3 -27
  72. data/config/locales/plans.en.yml +4 -0
  73. data/config/locales/plans.ko.yml +4 -0
  74. data/config/locales/users.en.yml +3 -0
  75. data/config/locales/users.ko.yml +3 -0
  76. data/config/routes.rb +8 -3
  77. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +1 -1
  78. data/db/migrate/20260131100000_migrate_active_storage_attachment_record_types.rb +21 -0
  79. data/db/migrate/20260201100000_make_google_event_id_nullable.rb +5 -0
  80. data/lib/collavre/engine.rb +171 -6
  81. data/lib/collavre/integration_registry.rb +129 -0
  82. data/lib/collavre/version.rb +1 -1
  83. data/lib/collavre.rb +2 -0
  84. data/lib/navigation/registry.rb +130 -0
  85. metadata +22 -15
  86. data/app/components/collavre/user_mention_menu_component.html.erb +0 -3
  87. data/app/controllers/collavre/notion_auth_controller.rb +0 -25
  88. data/app/jobs/collavre/notion_export_job.rb +0 -30
  89. data/app/jobs/collavre/notion_sync_job.rb +0 -48
  90. data/app/models/collavre/notion_account.rb +0 -17
  91. data/app/models/collavre/notion_block_link.rb +0 -10
  92. data/app/models/collavre/notion_page_link.rb +0 -19
  93. data/app/services/collavre/notion_client.rb +0 -231
  94. data/app/services/collavre/notion_creative_exporter.rb +0 -296
  95. data/app/services/collavre/notion_service.rb +0 -249
  96. data/app/views/collavre/creatives/_notion_integration_modal.html.erb +0 -90
  97. data/db/migrate/20241201000000_create_notion_integrations.rb +0 -29
  98. data/db/migrate/20250312000000_create_notion_block_links.rb +0 -16
  99. data/db/migrate/20250312010000_allow_multiple_notion_blocks_per_creative.rb +0 -5
@@ -1,6 +1,8 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import { copyTextToClipboard } from '../../utils/clipboard'
3
3
  import { renderMarkdownInContainer } from '../../lib/utils/markdown'
4
+ import creativesApi from '../../lib/api/creatives'
5
+ import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
4
6
 
5
7
  export default class extends Controller {
6
8
  static targets = ['list']
@@ -44,6 +46,14 @@ export default class extends Controller {
44
46
 
45
47
  this.handleTopicChange = this.handleTopicChange.bind(this)
46
48
  this.element.addEventListener('comments--topics:change', this.handleTopicChange)
49
+
50
+ // Drag and drop handlers for moving comments to topics
51
+ this.handleDragStart = this.handleDragStart.bind(this)
52
+ this.handleDragEnd = this.handleDragEnd.bind(this)
53
+ this.handleMoveToTopic = this.handleMoveToTopic.bind(this)
54
+ this.listTarget.addEventListener('dragstart', this.handleDragStart)
55
+ this.listTarget.addEventListener('dragend', this.handleDragEnd)
56
+ this.element.addEventListener('comments--topics:move-to-topic', this.handleMoveToTopic)
47
57
  }
48
58
 
49
59
  handleTopicChange(event) {
@@ -56,12 +66,15 @@ export default class extends Controller {
56
66
  this.listTarget.removeEventListener('change', this.handleChange)
57
67
  this.listTarget.removeEventListener('click', this.handleClick)
58
68
  this.listTarget.removeEventListener('submit', this.handleSubmit)
69
+ this.listTarget.removeEventListener('dragstart', this.handleDragStart)
70
+ this.listTarget.removeEventListener('dragend', this.handleDragEnd)
59
71
  document.removeEventListener('turbo:before-stream-render', this.handleStreamRender)
60
72
  if (this.listObserver) {
61
73
  this.listObserver.disconnect()
62
74
  this.listObserver = null
63
75
  }
64
76
  this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
77
+ this.element.removeEventListener('comments--topics:move-to-topic', this.handleMoveToTopic)
65
78
  }
66
79
 
67
80
  isColumnReverse() {
@@ -459,12 +472,154 @@ export default class extends Controller {
459
472
  const item = checkbox.closest('.comment-item')
460
473
  if (checkbox.checked) {
461
474
  this.selection.add(commentId)
462
- if (item) item.classList.add('selected-for-move')
475
+ if (item) {
476
+ item.classList.add('selected-for-move')
477
+ item.setAttribute('draggable', 'true')
478
+ }
463
479
  } else {
464
480
  this.selection.delete(commentId)
465
- if (item) item.classList.remove('selected-for-move')
481
+ if (item) {
482
+ item.classList.remove('selected-for-move')
483
+ // Only remove draggable if no other items selected
484
+ if (this.selection.size === 0) {
485
+ item.removeAttribute('draggable')
486
+ }
487
+ }
466
488
  }
489
+ this.updateDraggableState()
467
490
  this.notifySelectionChange()
491
+ this.updateSelectionHint()
492
+ }
493
+
494
+ updateSelectionHint() {
495
+ // Remove existing hint
496
+ const existingHint = document.querySelector('.selection-hint-popup')
497
+ if (existingHint) {
498
+ existingHint.remove()
499
+ }
500
+
501
+ if (this.selection.size === 0) return
502
+
503
+ // Find the first selected checkbox
504
+ const firstSelected = this.listTarget.querySelector('.comment-item.selected-for-move')
505
+ if (!firstSelected) return
506
+
507
+ const checkbox = firstSelected.querySelector('.comment-select-checkbox')
508
+ if (!checkbox) return
509
+
510
+ // Create hint popup
511
+ const hint = document.createElement('div')
512
+ hint.className = 'selection-hint-popup'
513
+ const dragTopicText = this.element.dataset.hintDragTopicText || '🎯 Drag → Move to topic'
514
+ const moveButtonText = this.element.dataset.hintMoveButtonText || '📤 Move button → Another chat'
515
+ hint.innerHTML = `
516
+ <div class="hint-content">
517
+ <span>${dragTopicText}</span>
518
+ <span>${moveButtonText}</span>
519
+ </div>
520
+ `
521
+
522
+ // Append to body for proper positioning
523
+ document.body.appendChild(hint)
524
+
525
+ // Position like CommonPopup - to the right of checkbox, within viewport
526
+ requestAnimationFrame(() => {
527
+ const checkboxRect = checkbox.getBoundingClientRect()
528
+ const hintRect = hint.getBoundingClientRect()
529
+ const boundsPadding = 8
530
+
531
+ // Start to the right of the checkbox, vertically centered
532
+ let left = checkboxRect.right + 8
533
+ let top = checkboxRect.top + (checkboxRect.height / 2) - (hintRect.height / 2)
534
+
535
+ // Keep within viewport bounds
536
+ const maxLeft = window.innerWidth - hintRect.width - boundsPadding
537
+ const maxTop = window.innerHeight - hintRect.height - boundsPadding
538
+
539
+ // If overflows right, position to the left of checkbox instead
540
+ if (left > maxLeft) {
541
+ left = checkboxRect.left - hintRect.width - 8
542
+ }
543
+
544
+ left = Math.max(boundsPadding, Math.min(left, maxLeft))
545
+ top = Math.max(boundsPadding, Math.min(top, maxTop))
546
+
547
+ hint.style.left = `${left}px`
548
+ hint.style.top = `${top}px`
549
+ })
550
+ }
551
+
552
+ updateDraggableState() {
553
+ const hasSelection = this.selection.size > 0
554
+ this.listTarget.querySelectorAll('.comment-item').forEach((item) => {
555
+ const checkbox = item.querySelector('.comment-select-checkbox')
556
+ if (checkbox?.checked) {
557
+ item.setAttribute('draggable', 'true')
558
+ } else {
559
+ item.removeAttribute('draggable')
560
+ }
561
+ })
562
+ }
563
+
564
+ handleDragStart(event) {
565
+ const item = event.target.closest('.comment-item')
566
+ if (!item || this.selection.size === 0) {
567
+ event.preventDefault()
568
+ return
569
+ }
570
+
571
+ // Include all selected comment IDs
572
+ const commentIds = Array.from(this.selection)
573
+ event.dataTransfer.setData('application/x-comment-ids', JSON.stringify(commentIds))
574
+ event.dataTransfer.effectAllowed = 'move'
575
+
576
+ // Add visual feedback
577
+ this.listTarget.classList.add('dragging-comments')
578
+
579
+ // Create custom drag image showing count
580
+ if (commentIds.length > 1) {
581
+ const dragImage = document.createElement('div')
582
+ dragImage.className = 'comment-drag-image'
583
+ dragImage.textContent = `${commentIds.length} messages`
584
+ document.body.appendChild(dragImage)
585
+ event.dataTransfer.setDragImage(dragImage, 0, 0)
586
+ setTimeout(() => dragImage.remove(), 0)
587
+ }
588
+ }
589
+
590
+ handleDragEnd(event) {
591
+ this.listTarget.classList.remove('dragging-comments')
592
+ }
593
+
594
+ async handleMoveToTopic(event) {
595
+ const { commentIds, targetTopicId } = event.detail
596
+ if (!commentIds || commentIds.length === 0 || !this.creativeId) return
597
+
598
+ try {
599
+ const response = await fetch(`/creatives/${this.creativeId}/comments/move`, {
600
+ method: 'POST',
601
+ headers: {
602
+ 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
603
+ 'Content-Type': 'application/json',
604
+ Accept: 'application/json'
605
+ },
606
+ body: JSON.stringify({
607
+ comment_ids: commentIds,
608
+ target_topic_id: targetTopicId
609
+ })
610
+ })
611
+
612
+ if (response.ok) {
613
+ this.clearSelection()
614
+ this.loadInitialComments()
615
+ } else {
616
+ const data = await response.json()
617
+ alert(data.error || 'Failed to move comments')
618
+ }
619
+ } catch (error) {
620
+ console.error('Error moving comments to topic:', error)
621
+ alert('Failed to move comments')
622
+ }
468
623
  }
469
624
 
470
625
  notifySelectionChange() {
@@ -480,6 +635,9 @@ export default class extends Controller {
480
635
  if (item) item.classList.remove('selected-for-move')
481
636
  })
482
637
  this.notifySelectionChange()
638
+ // Remove hint popup from body
639
+ const hint = document.querySelector('.selection-hint-popup')
640
+ if (hint) hint.remove()
483
641
  }
484
642
 
485
643
  copyCommentLink(button) {
@@ -562,10 +720,35 @@ export default class extends Controller {
562
720
  // Conversion usually converts to creative, so maybe reload or redirect?
563
721
  // Original code reloaded initial comments. Safe to do:
564
722
  this.loadInitialComments()
723
+ this.reloadCreativeChildren()
565
724
  }
566
725
  })
567
726
  }
568
727
 
728
+ reloadCreativeChildren() {
729
+ if (!this.creativeId) return Promise.resolve()
730
+ const container = document.getElementById(`creative-children-${this.creativeId}`)
731
+ const loadUrl = container?.dataset?.loadUrl
732
+ if (!container || !loadUrl) {
733
+ this.reloadCreativeTree()
734
+ return Promise.resolve()
735
+ }
736
+
737
+ return creativesApi.loadChildren(loadUrl).then((data) => {
738
+ const nodes = Array.isArray(data?.creatives) ? data.creatives : []
739
+ renderCreativeTree(container, nodes, { replace: false })
740
+ container.dataset.loaded = 'true'
741
+ dispatchCreativeTreeUpdated(container)
742
+ })
743
+ }
744
+
745
+ reloadCreativeTree() {
746
+ const treeElement = document.getElementById('creatives')
747
+ if (!treeElement) return
748
+ const controller = this.application.getControllerForElementAndIdentifier(treeElement, 'creatives--tree')
749
+ controller?.load?.()
750
+ }
751
+
569
752
  approveComment(button) {
570
753
  // ... (Existing logic) ...
571
754
  if (button.disabled) return
@@ -11,6 +11,7 @@ export default class extends Controller {
11
11
  'closeButton',
12
12
  'leftHandle',
13
13
  'rightHandle',
14
+ 'fullscreenLink',
14
15
  ]
15
16
 
16
17
  connect() {
@@ -36,23 +37,36 @@ export default class extends Controller {
36
37
  window.addEventListener('focus', this.handleWindowFocus)
37
38
  document.addEventListener('visibilitychange', this.handleVisibilityChange)
38
39
 
39
- this.closeButtonTarget?.addEventListener('click', () => this.close())
40
- this.leftHandleTarget?.addEventListener('mousedown', (event) => this.startResize(event, 'left'))
41
- this.rightHandleTarget?.addEventListener('mousedown', (event) => this.startResize(event, 'right'))
40
+ if (this.hasCloseButtonTarget) {
41
+ this.closeButtonTarget.addEventListener('click', () => this.close())
42
+ }
43
+ if (this.hasLeftHandleTarget) {
44
+ this.leftHandleTarget.addEventListener('mousedown', (event) => this.startResize(event, 'left'))
45
+ }
46
+ if (this.hasRightHandleTarget) {
47
+ this.rightHandleTarget.addEventListener('mousedown', (event) => this.startResize(event, 'right'))
48
+ }
42
49
 
43
50
  if (this.isMobile()) {
44
51
  // Handle touch events directly on the close button to resolve issues on mobile where layout shifts (e.g., keyboard dismissal) cause click events to be lost or delayed.
45
52
  this.element.addEventListener('touchstart', this.handleTouchStart)
46
53
  this.element.addEventListener('touchend', this.handleTouchEnd)
47
- this.closeButtonTarget?.addEventListener('touchstart', this.handleCloseButtonTouchStart, { passive: false })
48
- this.closeButtonTarget?.addEventListener('touchend', this.handleCloseButtonTouchEnd)
54
+ if (this.hasCloseButtonTarget) {
55
+ this.closeButtonTarget.addEventListener('touchstart', this.handleCloseButtonTouchStart, { passive: false })
56
+ this.closeButtonTarget.addEventListener('touchend', this.handleCloseButtonTouchEnd)
57
+ }
49
58
  }
50
59
 
51
60
  document.querySelectorAll('form[action="/session"]').forEach((form) => {
52
61
  form.addEventListener('submit', () => window.localStorage.removeItem(SIZE_STORAGE_KEY))
53
62
  })
54
63
 
55
- this.openFromUrl()
64
+ if (this.isFullscreen()) {
65
+ // Defer to ensure all sibling controllers are connected
66
+ requestAnimationFrame(() => this.openForCreative())
67
+ } else {
68
+ this.openFromUrl()
69
+ }
56
70
  }
57
71
 
58
72
  disconnect() {
@@ -66,8 +80,10 @@ export default class extends Controller {
66
80
  if (this.isMobile()) {
67
81
  this.element.removeEventListener('touchstart', this.handleTouchStart)
68
82
  this.element.removeEventListener('touchend', this.handleTouchEnd)
69
- this.closeButtonTarget?.removeEventListener('touchstart', this.handleCloseButtonTouchStart)
70
- this.closeButtonTarget?.removeEventListener('touchend', this.handleCloseButtonTouchEnd)
83
+ if (this.hasCloseButtonTarget) {
84
+ this.closeButtonTarget.removeEventListener('touchstart', this.handleCloseButtonTouchStart)
85
+ this.closeButtonTarget.removeEventListener('touchend', this.handleCloseButtonTouchEnd)
86
+ }
71
87
  }
72
88
  }
73
89
 
@@ -115,29 +131,70 @@ export default class extends Controller {
115
131
  this.element.dataset.canComment = canComment ? 'true' : 'false'
116
132
  this.titleTarget.textContent = snippet
117
133
 
134
+ this.updateFullscreenLink(resolvedCreativeId)
135
+
118
136
  this.prepareSize()
119
137
 
120
138
  this.showPopup()
121
139
  this.updatePosition()
122
140
  document.body.classList.add('no-scroll')
123
141
 
142
+ await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment, highlightId })
143
+
144
+ // Dispatch event for integrations (e.g., Slack badge)
145
+ this.element.dispatchEvent(new CustomEvent('comments-popup:opened', {
146
+ bubbles: true,
147
+ detail: {
148
+ creativeId: resolvedCreativeId,
149
+ badgeContainer: this.element.querySelector('[data-integration-badges]')
150
+ }
151
+ }))
152
+ }
153
+
154
+ async openForCreative() {
155
+ const resolvedCreativeId = this.element.dataset.creativeId
156
+ const canComment = this.element.dataset.canComment === 'true'
157
+ const snippet = this.element.dataset.creativeSnippet || ''
158
+
159
+ this.currentButton = null
160
+ this.element.dataset.creativeId = resolvedCreativeId || ''
161
+ this.element.dataset.canComment = canComment ? 'true' : 'false'
162
+ this.titleTarget.textContent = snippet
163
+
164
+ this.updateFullscreenLink(resolvedCreativeId)
165
+
166
+ this.showPopup()
167
+
168
+ await this.notifyChildControllers({ creativeId: resolvedCreativeId, canComment })
169
+
170
+ // Dispatch event for integrations (e.g., Slack badge)
171
+ this.element.dispatchEvent(new CustomEvent('comments-popup:opened', {
172
+ bubbles: true,
173
+ detail: {
174
+ creativeId: resolvedCreativeId,
175
+ badgeContainer: this.element.querySelector('[data-integration-badges]')
176
+ }
177
+ }))
178
+ }
179
+
180
+ async notifyChildControllers({ creativeId, canComment, highlightId }) {
124
181
  // Load topics first to establish context
125
182
  if (this.topicsController) {
126
- await this.topicsController.onPopupOpened({ creativeId: resolvedCreativeId })
183
+ await this.topicsController.onPopupOpened({ creativeId })
127
184
  }
128
185
 
129
186
  if (this.formController) {
130
- this.formController.onPopupOpened({ creativeId: resolvedCreativeId, canComment })
187
+ this.formController.onPopupOpened({ creativeId, canComment })
131
188
  }
132
189
  if (this.listController) {
133
190
  const topicId = this.topicsController ? this.topicsController.currentTopicId : undefined
134
- this.listController.onPopupOpened({ creativeId: resolvedCreativeId, highlightId, topicId })
191
+ this.listController.onPopupOpened({ creativeId, highlightId, topicId })
135
192
  }
136
193
  if (this.presenceController) {
137
- this.presenceController.onPopupOpened({ creativeId: resolvedCreativeId })
194
+ this.presenceController.onPopupOpened({ creativeId })
138
195
  }
139
196
  if (this.mentionMenuController) {
140
- this.mentionMenuController.onPopupOpened({ creativeId: resolvedCreativeId })
197
+ this.mentionMenuController.onPopupOpened({ creativeId })
141
198
  }
142
199
  }
143
200
 
@@ -158,6 +215,14 @@ export default class extends Controller {
158
215
  this.topicsController.onPopupClosed()
159
216
  }
160
217
 
218
+ // Dispatch event for integrations
219
+ this.element.dispatchEvent(new CustomEvent('comments-popup:closed', {
220
+ bubbles: true,
221
+ detail: {
222
+ badgeContainer: this.element.querySelector('[data-integration-badges]')
223
+ }
224
+ }))
225
+
161
226
  this.element.style.display = 'none'
162
227
  this.element.classList.remove('open')
163
228
  this.element.style.width = ''
@@ -187,20 +252,22 @@ export default class extends Controller {
187
252
 
188
253
 
189
254
  showPopup() {
255
+ this.element.style.display = 'flex'
190
256
  if (this.isMobile()) {
191
- this.element.style.display = 'flex'
192
257
  this.element.classList.add('open')
193
- } else {
194
- this.element.style.display = 'flex'
195
258
  }
196
259
  }
197
260
 
261
+ isFullscreen() {
262
+ return this.element.dataset.fullscreen === 'true'
263
+ }
264
+
198
265
  isMobile() {
199
266
  return window.innerWidth <= 600
200
267
  }
201
268
 
202
269
  updatePosition() {
203
- if (!this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
270
+ if (this.isFullscreen() || !this.currentButton || this.isMobile() || this.element.dataset.resized === 'true') return
204
271
  const rect = this.currentButton.getBoundingClientRect()
205
272
  const scrollY = window.scrollY || window.pageYOffset
206
273
  let top = rect.bottom + scrollY + 4
@@ -321,8 +388,16 @@ export default class extends Controller {
321
388
  }
322
389
  }
323
390
 
391
+ updateFullscreenLink(creativeId) {
392
+ if (!this.hasFullscreenLinkTarget || !creativeId) return
393
+ const template = this.element.dataset.fullscreenUrlTemplate
394
+ if (!template) return
395
+ this.fullscreenLinkTarget.href = template.replace('__CREATIVE_ID__', creativeId)
396
+ }
397
+
324
398
  openFromUrl() {
325
399
  const params = new URLSearchParams(window.location.search)
400
+ const openComments = params.get('open_comments') === 'true'
326
401
  let commentId = params.get('comment_id')
327
402
  if (!commentId) {
328
403
  const pathCommentMatch = window.location.pathname.match(/\/creatives\/\d+\/comments\/(\d+)/)
@@ -345,13 +420,14 @@ export default class extends Controller {
345
420
  }
346
421
  }
347
422
 
348
- if (!commentId || !creativeId) return
423
+ // Need either open_comments flag or comment_id, plus creativeId
424
+ if ((!commentId && !openComments) || !creativeId) return
349
425
  const selector = `[name="show-comments-btn"][data-creative-id="${creativeId}"]`
350
426
  const tryOpenWithButton = () => {
351
427
  const button = document.querySelector(selector)
352
428
  if (!button) return false
353
429
  this.clearPendingOpenFromUrl()
354
- this.open(button, { highlightId: commentId })
430
+ this.open(button, { highlightId: commentId || undefined })
355
431
  return true
356
432
  }
357
433
 
@@ -381,5 +457,4 @@ export default class extends Controller {
381
457
  this.openFromUrlTimeout = null
382
458
  }
383
459
  }
384
-
385
460
  }
@@ -91,7 +91,8 @@ export default class extends Controller {
91
91
  fetch(`/creatives/${this.creativeId}/comments/participants`)
92
92
  .then((response) => response.json())
93
93
  .then((data) => {
94
- this.participantsData = data
94
+ this.participantsData = data.users
95
+ this.canShare = data.can_share
95
96
  this.renderParticipants(this.currentPresentIds)
96
97
  this.renderTypingIndicator()
97
98
  })
@@ -149,6 +150,15 @@ export default class extends Controller {
149
150
  }
150
151
  this.renderTypingIndicator()
151
152
  }
153
+ if (data.agent_status) {
154
+ const { id, name, status } = data.agent_status
155
+ if (status === 'thinking' || status === 'streaming') {
156
+ this.typingUsers[id] = name
157
+ } else {
158
+ delete this.typingUsers[id]
159
+ }
160
+ this.renderTypingIndicator()
161
+ }
152
162
  }
153
163
 
154
164
  renderParticipants(presentIds) {
@@ -190,9 +200,28 @@ export default class extends Controller {
190
200
  this.participantsTarget.appendChild(wrapper)
191
201
  })
192
202
 
203
+ if (this.canShare) {
204
+ const addBtn = document.createElement('button')
205
+ addBtn.className = 'add-participant-btn'
206
+ addBtn.textContent = '+'
207
+ addBtn.title = this.element.dataset.addParticipantText || 'Add user'
208
+ addBtn.addEventListener('click', () => this.openShareModal())
209
+ this.participantsTarget.appendChild(addBtn)
210
+ }
211
+
193
212
  this.updateReadReceiptPresence(presentIds)
194
213
  }
195
214
 
215
+ openShareModal() {
216
+ const modal = document.getElementById('share-creative-modal')
217
+ if (modal) {
218
+ modal.style.display = 'flex'
219
+ document.body.classList.add('no-scroll')
220
+ } else if (this.creativeId) {
221
+ window.location.href = `/creatives/${this.creativeId}?open_comments=true&open_share=true`
222
+ }
223
+ }
224
+
196
225
  renderTypingIndicator() {
197
226
  if (!this.hasTypingIndicatorTarget) return
198
227
  this.typingIndicatorTarget.innerHTML = ''