collavre 0.5.0 → 0.7.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 (133) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/comment_versions.css +76 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +347 -37
  4. data/app/assets/stylesheets/collavre/creatives.css +73 -1
  5. data/app/assets/stylesheets/collavre/org_chart.css +319 -0
  6. data/app/assets/stylesheets/collavre/popup.css +68 -1
  7. data/app/controllers/collavre/application_controller.rb +13 -0
  8. data/app/controllers/collavre/comments/versions_controller.rb +82 -0
  9. data/app/controllers/collavre/comments_controller.rb +14 -153
  10. data/app/controllers/collavre/concerns/exportable.rb +30 -0
  11. data/app/controllers/collavre/concerns/shareable.rb +28 -0
  12. data/app/controllers/collavre/concerns/slide_viewable.rb +37 -0
  13. data/app/controllers/collavre/concerns/tree_manageable.rb +141 -0
  14. data/app/controllers/collavre/creative_imports_controller.rb +6 -0
  15. data/app/controllers/collavre/creative_invitations_controller.rb +46 -0
  16. data/app/controllers/collavre/creative_plans_controller.rb +1 -1
  17. data/app/controllers/collavre/creative_shares_controller.rb +84 -14
  18. data/app/controllers/collavre/creatives_controller.rb +70 -194
  19. data/app/controllers/collavre/google_auth_controller.rb +3 -0
  20. data/app/controllers/collavre/invites_controller.rb +2 -1
  21. data/app/controllers/collavre/sessions_controller.rb +3 -0
  22. data/app/controllers/collavre/topics_controller.rb +39 -2
  23. data/app/controllers/collavre/users_controller.rb +5 -404
  24. data/app/controllers/concerns/collavre/comments/approval_actions.rb +108 -0
  25. data/app/controllers/concerns/collavre/comments/batch_operations.rb +55 -0
  26. data/app/controllers/concerns/collavre/comments/conversion.rb +46 -0
  27. data/app/controllers/concerns/collavre/users_controller/admin_operations.rb +74 -0
  28. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +119 -0
  29. data/app/controllers/concerns/collavre/users_controller/contact_management.rb +166 -0
  30. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +102 -0
  31. data/app/controllers/concerns/collavre/users_controller/registration.rb +63 -0
  32. data/app/helpers/collavre/application_helper.rb +1 -0
  33. data/app/helpers/collavre/creatives_helper.rb +12 -9
  34. data/app/helpers/collavre/navigation_helper.rb +1 -1
  35. data/app/javascript/collavre.js +0 -1
  36. data/app/javascript/controllers/comment_controller.js +33 -70
  37. data/app/javascript/controllers/comment_version_controller.js +164 -0
  38. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +305 -0
  39. data/app/javascript/controllers/comments/__tests__/list_controller_selection.test.js +103 -0
  40. data/app/javascript/controllers/comments/__tests__/review_quotes_store.test.js +113 -0
  41. data/app/javascript/controllers/comments/contexts_controller.js +363 -0
  42. data/app/javascript/controllers/comments/form_controller.js +304 -13
  43. data/app/javascript/controllers/comments/list_controller.js +151 -62
  44. data/app/javascript/controllers/comments/popup_controller.js +66 -38
  45. data/app/javascript/controllers/comments/presence_controller.js +2 -10
  46. data/app/javascript/controllers/comments/review_quotes_store.js +189 -0
  47. data/app/javascript/controllers/comments/topics_controller.js +34 -10
  48. data/app/javascript/controllers/index.js +15 -1
  49. data/app/javascript/controllers/org_chart_controller.js +46 -0
  50. data/app/javascript/controllers/share_modal_controller.js +369 -0
  51. data/app/javascript/controllers/topic_search_controller.js +103 -0
  52. data/app/javascript/creatives/drag_drop/event_handlers.js +42 -1
  53. data/app/javascript/lib/api/creatives.js +12 -0
  54. data/app/javascript/lib/api/csrf_fetch.js +35 -0
  55. data/app/javascript/lib/api/drag_drop.js +17 -0
  56. data/app/javascript/modules/command_menu.js +40 -0
  57. data/app/javascript/modules/creative_row_editor.js +88 -0
  58. data/app/javascript/modules/slide_view.js +2 -1
  59. data/app/jobs/collavre/ai_agent_job.rb +42 -30
  60. data/app/jobs/collavre/compress_job.rb +92 -0
  61. data/app/models/collavre/comment.rb +36 -1
  62. data/app/models/collavre/comment_version.rb +15 -0
  63. data/app/models/collavre/creative/describable.rb +1 -1
  64. data/app/models/collavre/creative.rb +51 -0
  65. data/app/models/collavre/task.rb +30 -2
  66. data/app/models/collavre/user.rb +20 -3
  67. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +68 -0
  68. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +89 -0
  69. data/app/services/collavre/ai_agent/message_builder.rb +85 -6
  70. data/app/services/collavre/ai_agent/response_finalizer.rb +97 -0
  71. data/app/services/collavre/ai_agent/response_streamer.rb +56 -0
  72. data/app/services/collavre/ai_agent/review_handler.rb +18 -1
  73. data/app/services/collavre/ai_agent_service.rb +130 -183
  74. data/app/services/collavre/ai_client.rb +6 -0
  75. data/app/services/collavre/auto_theme_generator.rb +1 -1
  76. data/app/services/collavre/command_menu_service.rb +19 -0
  77. data/app/services/collavre/comments/command_processor.rb +3 -1
  78. data/app/services/collavre/comments/compress_command.rb +75 -0
  79. data/app/services/collavre/comments/concerns/workflow_support.rb +115 -0
  80. data/app/services/collavre/comments/work_command.rb +161 -0
  81. data/app/services/collavre/comments/workflow_executor.rb +276 -0
  82. data/app/services/collavre/creatives/plan_tagger.rb +14 -3
  83. data/app/services/collavre/creatives/tree_formatter.rb +53 -13
  84. data/app/services/collavre/gemini_parent_recommender.rb +4 -4
  85. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -3
  86. data/app/services/collavre/orchestration/agent_orchestrator.rb +15 -4
  87. data/app/services/collavre/orchestration/policy_resolver.rb +0 -19
  88. data/app/services/collavre/orchestration/scheduler.rb +3 -2
  89. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  90. data/app/services/collavre/system_events/dispatcher.rb +9 -0
  91. data/app/services/collavre/tools/creative_create_service.rb +1 -8
  92. data/app/services/collavre/tools/creative_import_service.rb +46 -0
  93. data/app/services/collavre/tools/creative_retrieval_service.rb +157 -96
  94. data/app/services/collavre/tools/creative_update_service.rb +1 -8
  95. data/app/services/collavre/tools/cron_list_service.rb +1 -1
  96. data/app/services/collavre/tools/description_normalizable.rb +16 -0
  97. data/app/views/collavre/comments/_comment.html.erb +25 -8
  98. data/app/views/collavre/comments/_comments_popup.html.erb +32 -5
  99. data/app/views/collavre/creatives/_inline_edit_form.html.erb +13 -0
  100. data/app/views/collavre/creatives/_share_button.html.erb +4 -1
  101. data/app/views/collavre/creatives/_share_modal.html.erb +31 -1
  102. data/app/views/collavre/creatives/index.html.erb +5 -5
  103. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  104. data/app/views/collavre/users/{_contact_management.html.erb → _contact_list.html.erb} +4 -8
  105. data/app/views/collavre/users/_org_chart.html.erb +68 -0
  106. data/app/views/collavre/users/_org_chart_node.html.erb +169 -0
  107. data/app/views/collavre/users/new_ai.html.erb +9 -0
  108. data/app/views/collavre/users/show.html.erb +32 -8
  109. data/config/locales/comments.en.yml +57 -2
  110. data/config/locales/comments.ko.yml +57 -2
  111. data/config/locales/contacts.en.yml +31 -0
  112. data/config/locales/contacts.ko.yml +31 -0
  113. data/config/locales/contexts.en.yml +8 -0
  114. data/config/locales/contexts.ko.yml +8 -0
  115. data/config/locales/creatives.en.yml +6 -0
  116. data/config/locales/creatives.ko.yml +6 -0
  117. data/config/locales/users.en.yml +1 -0
  118. data/config/locales/users.ko.yml +1 -0
  119. data/config/routes.rb +14 -1
  120. data/db/migrate/20260220072200_add_workflow_fields_to_tasks.rb +12 -0
  121. data/db/migrate/20260223173533_add_review_type_to_comments.rb +5 -0
  122. data/db/migrate/20260225065200_create_comment_versions.rb +14 -0
  123. data/db/migrate/20260225074416_add_selected_version_id_to_comments.rb +7 -0
  124. data/lib/collavre/version.rb +1 -1
  125. metadata +47 -10
  126. data/app/javascript/lib/lexical/__tests__/action_text_attachment_node.test.jsx +0 -91
  127. data/app/javascript/lib/lexical/action_text_attachment_node.js +0 -459
  128. data/app/javascript/lib/lexical/dom_attachment_utils.js +0 -66
  129. data/app/javascript/modules/share_modal.js +0 -76
  130. data/app/javascript/modules/share_user_popup.js +0 -77
  131. data/app/services/collavre/orchestration/self_reflection_evaluator.rb +0 -231
  132. data/app/views/collavre/comments/_presence_avatars.html.erb +0 -8
  133. data/app/views/collavre/creatives/_delete_button.html.erb +0 -12
@@ -1,6 +1,8 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import { renderMarkdownInContainer } from '../../lib/utils/markdown'
3
3
  import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
4
+ import { refreshCsrfToken } from '../../lib/api/csrf_fetch'
5
+ import ReviewQuotesStore from './review_quotes_store'
4
6
 
5
7
  export default class extends Controller {
6
8
  static targets = [
@@ -9,7 +11,6 @@ export default class extends Controller {
9
11
  'submit',
10
12
  'privateCheckbox',
11
13
  'cancel',
12
- 'moveButton',
13
14
  'searchButton',
14
15
  'voiceButton',
15
16
  'imageInput',
@@ -19,12 +20,14 @@ export default class extends Controller {
19
20
  'quotedText',
20
21
  'quoteIndicator',
21
22
  'quoteIndicatorText',
23
+ 'reviewQuotesContainer',
22
24
  ]
23
25
 
24
26
  connect() {
25
27
  this.creativeId = null
26
28
  this.editingId = null
27
29
  this.sending = false
30
+ this._reviewStore = new ReviewQuotesStore()
28
31
  this.cachedImageFiles = null
29
32
 
30
33
  this.handleSubmit = this.handleSubmit.bind(this)
@@ -33,7 +36,6 @@ export default class extends Controller {
33
36
  this.handlePointerSend = this.handlePointerSend.bind(this)
34
37
  this.handleTouchSend = this.handleTouchSend.bind(this)
35
38
  this.handleCancel = this.handleCancel.bind(this)
36
- this.handleMoveClick = this.handleMoveClick.bind(this)
37
39
  this.handleSearch = this.handleSearch.bind(this)
38
40
  this.handleVoiceToggle = this.handleVoiceToggle.bind(this)
39
41
  this.handleRecognitionStart = this.handleRecognitionStart.bind(this)
@@ -50,7 +52,6 @@ export default class extends Controller {
50
52
  this.submitTarget.addEventListener('pointerup', this.handlePointerSend)
51
53
  this.submitTarget.addEventListener('touchend', this.handleTouchSend, { passive: false })
52
54
  this.cancelTarget?.addEventListener('click', this.handleCancel)
53
- this.moveButtonTarget?.addEventListener('click', this.handleMoveClick)
54
55
  this.searchButtonTarget?.addEventListener('click', this.handleSearch)
55
56
  this.voiceButtonTarget?.addEventListener('click', this.handleVoiceToggle)
56
57
 
@@ -66,6 +67,15 @@ export default class extends Controller {
66
67
  this.listening = false
67
68
  this.recognitionActive = false
68
69
 
70
+ // Auto-resize textarea
71
+ this._autoResize = () => {
72
+ const textarea = this.textareaTarget
73
+ textarea.style.height = 'auto'
74
+ const maxHeight = parseInt(getComputedStyle(textarea).lineHeight, 10) * 10 || 200
75
+ textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
76
+ }
77
+ this.textareaTarget.addEventListener('input', this._autoResize)
78
+
69
79
  this.textareaTarget.addEventListener('keydown', (event) => {
70
80
  if (event.key === 'Enter' && !event.shiftKey) {
71
81
  if (this.isMentionMenuVisible()) return
@@ -96,12 +106,12 @@ export default class extends Controller {
96
106
  this.submitTarget.removeEventListener('pointerup', this.handlePointerSend)
97
107
  this.submitTarget.removeEventListener('touchend', this.handleTouchSend)
98
108
  this.cancelTarget?.removeEventListener('click', this.handleCancel)
99
- this.moveButtonTarget?.removeEventListener('click', this.handleMoveClick)
100
109
  this.searchButtonTarget?.removeEventListener('click', this.handleSearch)
101
110
  this.voiceButtonTarget?.removeEventListener('click', this.handleVoiceToggle)
102
111
  this.teardownSpeechRecognition()
103
112
  this.imageButtonTarget?.removeEventListener('click', this.handleImageButtonClick)
104
113
  this.imageInputTarget?.removeEventListener('change', this.handleImageChange)
114
+ this.textareaTarget.removeEventListener('input', this._autoResize)
105
115
  this.textareaTarget.removeEventListener('dragover', this.handleDragOver)
106
116
  this.textareaTarget.removeEventListener('drop', this.handleDrop)
107
117
  this.textareaTarget.removeEventListener('paste', this.handlePaste)
@@ -132,8 +142,7 @@ export default class extends Controller {
132
142
  }
133
143
 
134
144
  onSelectionChanged({ size, moving }) {
135
- if (!this.moveButtonTarget) return
136
- this.moveButtonTarget.disabled = moving || size === 0
145
+ // Selection state now managed by list_controller action bar
137
146
  }
138
147
 
139
148
  focusTextarea() {
@@ -159,10 +168,14 @@ export default class extends Controller {
159
168
  this.editingId = null
160
169
  this.submitTarget.innerHTML = this.defaultSubmitHTML
161
170
  this.submitTarget.disabled = false
171
+ this.submitTarget.classList.remove('review-submit-btn')
162
172
  if (this.cancelTarget) this.cancelTarget.style.display = 'none'
163
173
  this.presenceController?.clearManualTypingMessage()
164
174
  this.clearImageAttachments()
165
175
  this.cancelQuote()
176
+ this.textareaTarget.placeholder = ''
177
+ // Reset textarea height after clearing content
178
+ this.textareaTarget.style.height = 'auto'
166
179
  }
167
180
 
168
181
  setSendingState(isSending) {
@@ -184,19 +197,45 @@ export default class extends Controller {
184
197
 
185
198
  handleSend(event) {
186
199
  event.preventDefault()
200
+
201
+ // If active quote exists, handle based on type
202
+ const store = this._reviewStore
203
+ if (store.hasActive && !store.isEmpty) {
204
+ const activeQuote = store.activeQuote
205
+ if (activeQuote && activeQuote.type === 'question') {
206
+ store.saveActiveFeedback(this.textareaTarget.value)
207
+ this._sendQuestionQuote(activeQuote)
208
+ return
209
+ }
210
+ this._commitActiveQuote()
211
+ return
212
+ }
213
+
187
214
  const hasText = this.textareaTarget.value.trim().length > 0
215
+ const hasQuotes = !store.isEmpty
188
216
  const hasImages = this.currentImageFiles().length > 0
189
- if (this.sending || (!hasText && !hasImages) || !this.creativeId) return
217
+ if (this.sending || (!hasText && !hasQuotes && !hasImages) || !this.creativeId) return
190
218
  this.sending = true
191
219
  this.setSendingState(true)
192
220
  this.presenceController?.stoppedTyping()
193
221
 
222
+ // Build final content from review quotes + user text
223
+ if (hasQuotes) {
224
+ store.backup(this.textareaTarget.value)
225
+ this.textareaTarget.value = store.buildContent(this.textareaTarget.value)
226
+ this._pendingReviewType = 'review'
227
+ }
228
+
194
229
  const wasPrivate = this.privateCheckboxTarget?.checked ?? false
195
230
 
196
231
  const formData = new FormData(this.formTarget)
197
232
  if (this.currentTopicId) {
198
233
  formData.append('comment[topic_id]', this.currentTopicId)
199
234
  }
235
+ if (this._pendingReviewType) {
236
+ formData.append('comment[review_type]', this._pendingReviewType)
237
+ this._pendingReviewType = null
238
+ }
200
239
 
201
240
  let url = `/creatives/${this.creativeId}/comments`
202
241
  let method = 'POST'
@@ -205,13 +244,26 @@ export default class extends Controller {
205
244
  method = 'PATCH'
206
245
  }
207
246
 
208
- fetch(url, {
247
+ const doFetch = () => fetch(url, {
209
248
  method,
210
249
  headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
211
250
  body: formData,
212
251
  })
252
+
253
+ doFetch()
213
254
  .then((response) => {
214
255
  if (response.ok) return response.text()
256
+ // On 422, the CSRF token may have gone stale (e.g. after an OS
257
+ // window switch). Refresh the token and retry once before giving up.
258
+ if (response.status === 422 && !this._hasRetried) {
259
+ this._hasRetried = true
260
+ return refreshCsrfToken().then(() => doFetch()).then((retryResp) => {
261
+ if (retryResp.ok) return retryResp.text()
262
+ return retryResp.json().then((json) => {
263
+ throw new Error(json.errors?.join(', ') || 'Unable to save comment')
264
+ })
265
+ })
266
+ }
215
267
  return response.json().then((json) => {
216
268
  throw new Error(json.errors?.join(', ') || 'Unable to save comment')
217
269
  })
@@ -257,9 +309,17 @@ export default class extends Controller {
257
309
  }
258
310
  })
259
311
  .catch((error) => {
312
+ // Restore review quotes state on failure so user doesn't lose work
313
+ if (this._reviewStore.hasBackup()) {
314
+ const restoredText = this._reviewStore.restore()
315
+ this.textareaTarget.value = restoredText || ''
316
+ this._renderReviewQuoteChips()
317
+ this._updateSubmitButton()
318
+ }
260
319
  alert(error?.message || 'Failed to submit comment')
261
320
  })
262
321
  .finally(() => {
322
+ this._hasRetried = false
263
323
  this.setSendingState(false)
264
324
  })
265
325
  }
@@ -280,11 +340,6 @@ export default class extends Controller {
280
340
  this.resetForm()
281
341
  }
282
342
 
283
- handleMoveClick(event) {
284
- event.preventDefault()
285
- this.listController?.openMoveModal()
286
- }
287
-
288
343
  handleSearch(event) {
289
344
  event.preventDefault()
290
345
  const query = this.textareaTarget.value.trim()
@@ -544,11 +599,247 @@ export default class extends Controller {
544
599
  this.focusTextarea()
545
600
  }
546
601
 
602
+ // Append a review quote as a visual chip above the textarea.
603
+ appendReviewQuote(commentId, selectedText) {
604
+ if (!selectedText) return
605
+
606
+ const store = this._reviewStore
607
+ store.saveActiveFeedback(this.textareaTarget.value)
608
+
609
+ if (commentId) {
610
+ this.quotedCommentIdTarget.value = commentId
611
+ }
612
+
613
+ store.add(commentId, selectedText)
614
+ this._renderReviewQuoteChips()
615
+ this._updateSubmitButton()
616
+
617
+ this.textareaTarget.value = ''
618
+ this.textareaTarget.placeholder = this._getI18nText('reviewFeedbackPlaceholder', 'Write feedback for this quote...')
619
+ this.focusTextarea()
620
+ }
621
+
622
+ _commitActiveQuote() {
623
+ const store = this._reviewStore
624
+ store.commitActive(this.textareaTarget.value)
625
+ this.textareaTarget.value = ''
626
+ this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
627
+ this._renderReviewQuoteChips()
628
+ this._updateSubmitButton()
629
+ this.focusTextarea()
630
+ }
631
+
632
+ // Send a single question quote immediately as a standalone comment.
633
+ _sendQuestionQuote(quote) {
634
+ if (this.sending || !this.creativeId) return
635
+
636
+ const store = this._reviewStore
637
+ const content = store.buildQuestionContent(quote)
638
+
639
+ store.remove(quote.id)
640
+ this.textareaTarget.value = ''
641
+
642
+ if (store.isEmpty) {
643
+ this.textareaTarget.placeholder = ''
644
+ } else if (!store.hasActive) {
645
+ this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
646
+ }
647
+ this._renderReviewQuoteChips()
648
+ this._updateSubmitButton()
649
+
650
+ // Send as a question comment with quoted_comment_id preserved + review_type=question
651
+ this.sending = true
652
+ const formData = new FormData()
653
+ formData.append('comment[content]', content)
654
+ formData.append('comment[review_type]', 'question')
655
+ if (quote.commentId) {
656
+ formData.append('comment[quoted_comment_id]', quote.commentId)
657
+ }
658
+ const isPrivate = this.privateCheckboxTarget?.checked ?? false
659
+ if (isPrivate) formData.append('comment[private]', '1')
660
+ if (this.currentTopicId) {
661
+ formData.append('comment[topic_id]', this.currentTopicId)
662
+ }
663
+
664
+ const url = `/creatives/${this.creativeId}/comments`
665
+ const doFetch = () => fetch(url, {
666
+ method: 'POST',
667
+ headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content },
668
+ body: formData,
669
+ })
670
+
671
+ doFetch()
672
+ .then((response) => {
673
+ if (response.ok) return response.text()
674
+ if (response.status === 422 && !this._hasRetried) {
675
+ this._hasRetried = true
676
+ return refreshCsrfToken().then(() => doFetch()).then((retryResp) => {
677
+ if (retryResp.ok) return retryResp.text()
678
+ return retryResp.json().then((json) => {
679
+ throw new Error(json.errors?.join(', ') || 'Unable to save comment')
680
+ })
681
+ })
682
+ }
683
+ return response.json().then((json) => {
684
+ throw new Error(json.errors?.join(', ') || 'Unable to save comment')
685
+ })
686
+ })
687
+ .then((html) => {
688
+ this.renderCommentHtml(html)
689
+ const listCtrl = this.application.getControllerForElementAndIdentifier(
690
+ document.querySelector('[data-controller~="comments--list"]'), 'comments--list'
691
+ )
692
+ if (listCtrl) {
693
+ listCtrl.scrollToBottom()
694
+ listCtrl.updateStickiness()
695
+ listCtrl.markCommentsRead()
696
+ }
697
+ })
698
+ .catch((error) => {
699
+ alert(error?.message || 'Failed to send question')
700
+ })
701
+ .finally(() => {
702
+ this._hasRetried = false
703
+ this.sending = false
704
+ })
705
+
706
+ this.focusTextarea()
707
+ }
708
+
709
+ _renderReviewQuoteChips() {
710
+ const container = this.reviewQuotesContainerTarget
711
+ const store = this._reviewStore
712
+ container.innerHTML = ''
713
+
714
+ if (store.isEmpty) {
715
+ container.style.display = 'none'
716
+ return
717
+ }
718
+
719
+ container.style.display = ''
720
+ store.quotes.forEach((quote) => {
721
+ const chip = document.createElement('div')
722
+ chip.className = 'review-quote-chip'
723
+ if (quote.id === store.activeId) {
724
+ chip.classList.add('review-quote-chip--active')
725
+ }
726
+
727
+ // Type toggle (review / question)
728
+ const typeToggle = document.createElement('button')
729
+ typeToggle.type = 'button'
730
+ typeToggle.className = `review-quote-type-toggle review-quote-type-toggle--${quote.type}`
731
+ typeToggle.textContent = quote.type === 'question'
732
+ ? this._getI18nText('reviewTypeQuestion', '❓')
733
+ : this._getI18nText('reviewTypeReview', '💬')
734
+ typeToggle.title = quote.type === 'question'
735
+ ? this._getI18nText('reviewTypeQuestionLabel', 'Question')
736
+ : this._getI18nText('reviewTypeReviewLabel', 'Review')
737
+ typeToggle.addEventListener('click', (e) => {
738
+ e.stopPropagation()
739
+ store.toggleType(quote.id)
740
+ this._renderReviewQuoteChips()
741
+ this._updateSubmitButton()
742
+ })
743
+
744
+ // Quote text
745
+ const textSpan = document.createElement('span')
746
+ textSpan.className = 'review-quote-chip-text'
747
+ const preview = quote.text.length > 60
748
+ ? quote.text.substring(0, 60) + '…'
749
+ : quote.text
750
+ textSpan.textContent = preview
751
+ textSpan.title = quote.text
752
+
753
+ // Click chip to edit its feedback
754
+ textSpan.addEventListener('click', () => {
755
+ if (quote.id === store.activeId) {
756
+ const commentEl = document.querySelector(`[data-comment-id="${quote.commentId}"]`)
757
+ if (commentEl) {
758
+ commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
759
+ commentEl.classList.add('comment-highlight')
760
+ setTimeout(() => commentEl.classList.remove('comment-highlight'), 2000)
761
+ }
762
+ return
763
+ }
764
+ store.saveActiveFeedback(this.textareaTarget.value)
765
+ store.activate(quote.id)
766
+ this.textareaTarget.value = quote.feedback || ''
767
+ this.textareaTarget.placeholder = this._getI18nText('reviewFeedbackPlaceholder', 'Write feedback for this quote...')
768
+ this._renderReviewQuoteChips()
769
+ this._updateSubmitButton()
770
+ this.focusTextarea()
771
+ })
772
+
773
+ // Feedback preview (shown when not active and has feedback)
774
+ const feedbackSpan = document.createElement('span')
775
+ feedbackSpan.className = 'review-quote-chip-feedback'
776
+ if (quote.feedback && quote.id !== store.activeId) {
777
+ const fbPreview = quote.feedback.length > 40
778
+ ? quote.feedback.substring(0, 40) + '…'
779
+ : quote.feedback
780
+ feedbackSpan.textContent = `→ ${fbPreview}`
781
+ }
782
+
783
+ // Remove button
784
+ const removeBtn = document.createElement('button')
785
+ removeBtn.type = 'button'
786
+ removeBtn.className = 'review-quote-chip-remove'
787
+ removeBtn.innerHTML = '×'
788
+ removeBtn.title = 'Remove'
789
+ removeBtn.addEventListener('click', (e) => {
790
+ e.stopPropagation()
791
+ const wasActive = quote.id === store.activeId
792
+ store.remove(quote.id)
793
+ if (wasActive) this.textareaTarget.value = ''
794
+ if (store.isEmpty) {
795
+ this.textareaTarget.placeholder = ''
796
+ } else if (!store.hasActive) {
797
+ this.textareaTarget.placeholder = this._getI18nText('reviewSummaryPlaceholder', 'Overall comment (optional)...')
798
+ }
799
+ this._renderReviewQuoteChips()
800
+ this._updateSubmitButton()
801
+ })
802
+
803
+ chip.appendChild(typeToggle)
804
+ chip.appendChild(textSpan)
805
+ if (quote.feedback && quote.id !== store.activeId) {
806
+ chip.appendChild(feedbackSpan)
807
+ }
808
+ chip.appendChild(removeBtn)
809
+ container.appendChild(chip)
810
+ })
811
+ }
812
+
813
+ _updateSubmitButton() {
814
+ const state = this._reviewStore.buttonState
815
+ if (state === 'normal') {
816
+ this.submitTarget.innerHTML = this.defaultSubmitHTML
817
+ this.submitTarget.classList.remove('review-submit-btn')
818
+ return
819
+ }
820
+
821
+ this.submitTarget.classList.add('review-submit-btn')
822
+ const labels = {
823
+ 'add': this._getI18nText('reviewAddQuote', '+ Add'),
824
+ 'send-review': this._getI18nText('reviewSend', 'Send review'),
825
+ 'send-question': this._getI18nText('reviewSendQuestion', 'Send question'),
826
+ }
827
+ this.submitTarget.textContent = labels[state]
828
+ }
829
+
830
+ _getI18nText(key, fallback) {
831
+ return this.element.dataset[key] || fallback
832
+ }
833
+
547
834
  cancelQuote() {
548
835
  this.quotedCommentIdTarget.value = ''
549
836
  this.quotedTextTarget.value = ''
550
837
  this.quoteIndicatorTarget.style.display = 'none'
551
838
  this.quoteIndicatorTextTarget.textContent = ''
839
+ this._reviewStore.clear()
840
+ this._renderReviewQuoteChips()
841
+ this._updateSubmitButton()
842
+ this.textareaTarget.placeholder = ''
552
843
  }
553
844
 
554
845
  renderCommentHtml(html, { replaceExisting = false } = {}) {