collavre 0.3.2 → 0.5.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +73 -71
  3. data/app/assets/stylesheets/collavre/activity_logs.css +18 -45
  4. data/app/assets/stylesheets/collavre/comments_popup.css +197 -35
  5. data/app/assets/stylesheets/collavre/creatives.css +101 -51
  6. data/app/assets/stylesheets/collavre/dark_mode.css +221 -88
  7. data/app/assets/stylesheets/collavre/design_tokens.css +334 -0
  8. data/app/assets/stylesheets/collavre/mention_menu.css +13 -9
  9. data/app/assets/stylesheets/collavre/popup.css +57 -27
  10. data/app/assets/stylesheets/collavre/slide_view.css +6 -6
  11. data/app/assets/stylesheets/collavre/user_menu.css +4 -5
  12. data/app/components/collavre/plans_timeline_component.html.erb +2 -2
  13. data/app/controllers/collavre/admin/orchestration_controller.rb +9 -2
  14. data/app/controllers/collavre/admin/settings_controller.rb +199 -0
  15. data/app/controllers/collavre/comments/reactions_controller.rb +1 -9
  16. data/app/controllers/collavre/comments_controller.rb +39 -162
  17. data/app/controllers/collavre/creatives_controller.rb +18 -58
  18. data/app/controllers/collavre/users_controller.rb +31 -3
  19. data/app/helpers/collavre/application_helper.rb +97 -0
  20. data/app/helpers/collavre/creatives_helper.rb +10 -202
  21. data/app/javascript/collavre.js +0 -1
  22. data/app/javascript/components/creative_tree_row.js +3 -2
  23. data/app/javascript/controllers/comment_controller.js +309 -4
  24. data/app/javascript/controllers/comments/form_controller.js +52 -0
  25. data/app/javascript/controllers/comments/presence_controller.js +13 -0
  26. data/app/javascript/controllers/creatives/tree_controller.js +2 -1
  27. data/app/javascript/controllers/link_creative_controller.js +29 -3
  28. data/app/javascript/lib/__tests__/html_code_block_wrapper.test.js +201 -0
  29. data/app/javascript/lib/html_code_block_wrapper.js +168 -0
  30. data/app/javascript/lib/utils/markdown.js +2 -1
  31. data/app/javascript/modules/creative_row_editor.js +5 -1
  32. data/app/javascript/utils/emoji_parser.js +21 -0
  33. data/app/jobs/collavre/ai_agent_job.rb +6 -2
  34. data/app/jobs/collavre/cron_action_job.rb +18 -6
  35. data/app/jobs/collavre/cron_scheduler_job.rb +112 -0
  36. data/app/models/collavre/comment/approvable.rb +50 -0
  37. data/app/models/collavre/comment/broadcastable.rb +119 -0
  38. data/app/models/collavre/comment/notifiable.rb +111 -0
  39. data/app/models/collavre/comment.rb +13 -258
  40. data/app/models/collavre/comment_reaction.rb +15 -0
  41. data/app/models/collavre/creative/describable.rb +86 -0
  42. data/app/models/collavre/creative/linkable.rb +77 -0
  43. data/app/models/collavre/creative/permissible.rb +103 -0
  44. data/app/models/collavre/creative.rb +3 -289
  45. data/app/models/collavre/orchestrator_policy.rb +1 -1
  46. data/app/models/collavre/system_setting.rb +27 -1
  47. data/app/models/collavre/user.rb +42 -0
  48. data/app/models/collavre/user_theme.rb +10 -0
  49. data/app/services/collavre/ai_agent/approval_handler.rb +110 -0
  50. data/app/services/collavre/ai_agent/message_builder.rb +129 -0
  51. data/app/services/collavre/ai_agent/review_handler.rb +70 -0
  52. data/app/services/collavre/ai_agent_service.rb +93 -150
  53. data/app/services/collavre/ai_client.rb +23 -4
  54. data/app/services/collavre/auto_theme_generator.rb +168 -50
  55. data/app/services/collavre/command_menu_service.rb +70 -0
  56. data/app/services/collavre/comment_move_service.rb +94 -0
  57. data/app/services/collavre/comments/action_executor.rb +10 -0
  58. data/app/services/collavre/comments/mcp_command.rb +1 -2
  59. data/app/services/collavre/creatives/create_service.rb +86 -0
  60. data/app/services/collavre/creatives/destroy_service.rb +41 -0
  61. data/app/services/collavre/creatives/index_query.rb +3 -0
  62. data/app/services/collavre/markdown_converter.rb +240 -0
  63. data/app/services/collavre/mention_parser.rb +63 -0
  64. data/app/services/collavre/orchestration/agent_context_builder.rb +24 -8
  65. data/app/services/collavre/orchestration/agent_orchestrator.rb +59 -10
  66. data/app/services/collavre/orchestration/loop_breaker.rb +12 -7
  67. data/app/services/collavre/orchestration/policy_resolver.rb +16 -2
  68. data/app/services/collavre/orchestration/scheduler.rb +4 -3
  69. data/app/services/collavre/orchestration/stuck_detector.rb +1 -1
  70. data/app/services/collavre/system_events/context_builder.rb +1 -6
  71. data/app/services/collavre/tools/creative_batch_service.rb +107 -0
  72. data/app/services/collavre/tools/creative_update_service.rb +17 -12
  73. data/app/services/collavre/tools/cron_create_service.rb +17 -5
  74. data/app/views/admin/shared/_tabs.html.erb +2 -1
  75. data/app/views/collavre/admin/orchestration/show.html.erb +11 -0
  76. data/app/views/collavre/admin/settings/_system_tab.html.erb +138 -0
  77. data/app/views/collavre/admin/settings/_uiux_tab.html.erb +44 -0
  78. data/app/views/collavre/admin/settings/index.html.erb +11 -0
  79. data/app/views/collavre/admin/settings/uiux.html.erb +11 -0
  80. data/app/views/collavre/comments/_comment.html.erb +15 -5
  81. data/app/views/collavre/comments/_comments_popup.html.erb +9 -2
  82. data/app/views/collavre/creatives/_mobile_actions_menu.html.erb +0 -3
  83. data/app/views/collavre/creatives/_share_button.html.erb +0 -52
  84. data/app/views/collavre/creatives/_share_modal.html.erb +52 -0
  85. data/app/views/collavre/creatives/index.html.erb +5 -8
  86. data/app/views/collavre/shared/navigation/_panels.html.erb +2 -2
  87. data/app/views/collavre/user_themes/index.html.erb +7 -9
  88. data/app/views/collavre/users/_contact_management.html.erb +2 -1
  89. data/app/views/collavre/users/edit_ai.html.erb +7 -0
  90. data/app/views/collavre/users/index.html.erb +16 -1
  91. data/app/views/collavre/users/new_ai.html.erb +18 -8
  92. data/app/views/collavre/users/passkeys.html.erb +1 -1
  93. data/app/views/collavre/users/show.html.erb +1 -1
  94. data/app/views/layouts/collavre/slide.html.erb +8 -1
  95. data/config/locales/admin.en.yml +88 -0
  96. data/config/locales/admin.ko.yml +88 -0
  97. data/config/locales/ai_agent.en.yml +5 -1
  98. data/config/locales/ai_agent.ko.yml +5 -1
  99. data/config/locales/comments.en.yml +5 -1
  100. data/config/locales/comments.ko.yml +5 -1
  101. data/config/locales/orchestration.en.yml +8 -0
  102. data/config/locales/orchestration.ko.yml +8 -0
  103. data/config/locales/users.en.yml +12 -0
  104. data/config/locales/users.ko.yml +12 -0
  105. data/config/routes.rb +7 -1
  106. data/db/migrate/20260212011655_add_quoted_comment_to_comments.rb +7 -0
  107. data/db/migrate/20260213044247_add_agent_conf_to_users.rb +5 -0
  108. data/lib/collavre/engine.rb +25 -0
  109. data/lib/collavre/version.rb +1 -1
  110. metadata +32 -1
@@ -1,7 +1,3 @@
1
- require "base64"
2
- require "securerandom"
3
- require "nokogiri"
4
-
5
1
  module Collavre
6
2
  module CreativesHelper
7
3
  # Shared toggle button symbol helper
@@ -93,7 +89,7 @@ module Collavre
93
89
  !!(expanded_state_map && expanded_state_map[creative_id.to_s])
94
90
  end
95
91
 
96
- def render_creative_tree_markdown(creatives, level = 1, with_progress = false)
92
+ def render_creative_tree_markdown(creatives, level = 1, with_progress = false, max_depth: nil)
97
93
  return "" if creatives.blank?
98
94
  md = ""
99
95
  creatives.each do |creative|
@@ -103,18 +99,18 @@ module Collavre
103
99
  desc = "#{desc} (#{pct}%)"
104
100
  end
105
101
  raw_html = desc.gsub(/<!--.*?-->/m, "").strip
106
- markdown_content = html_links_to_markdown(raw_html)
102
+ markdown_content = MarkdownConverter.html_to_markdown(raw_html)
107
103
  cleaned_markdown = markdown_content.strip
108
104
  rendered_table_block = false
109
105
 
110
106
  table_match = cleaned_markdown.match(/^<div[^>]*>\s*<div[^>]*>\s*(\|.*?\|(?:\n\|.*?\|)*)\s*<\/div>\s*<\/div>$/m)
111
107
  if level <= 4 && table_match
112
108
  table_content = table_match[1].strip
113
- if markdown_table_block?(table_content)
109
+ if MarkdownConverter.table_block?(table_content)
114
110
  md += "#{table_content}\n\n"
115
111
  rendered_table_block = true
116
112
  end
117
- elsif level <= 4 && markdown_table_block?(cleaned_markdown)
113
+ elsif level <= 4 && MarkdownConverter.table_block?(cleaned_markdown)
118
114
  md += "#{cleaned_markdown}\n\n"
119
115
  rendered_table_block = true
120
116
  elsif level <= 4
@@ -134,210 +130,22 @@ module Collavre
134
130
  md += "#{indent}* #{inner}\n"
135
131
  end
136
132
  children = creative.linked_children
137
- if children.present?
138
- md += render_creative_tree_markdown(children, level + 1, with_progress)
133
+ if children.present? && (max_depth.nil? || level < max_depth)
134
+ md += render_creative_tree_markdown(children, level + 1, with_progress, max_depth: max_depth)
139
135
  end
140
136
  md += "\n" if level <= 4 && !rendered_table_block
141
137
  end
142
138
  md
143
139
  end
144
140
 
141
+ # Delegate to MarkdownConverter for backward compatibility.
142
+ # These methods are used in views and by MarkdownImporter via ApplicationController.helpers.
145
143
  def markdown_links_to_html(text, image_refs = {})
146
- return "" if text.nil?
147
- html = text.dup
148
-
149
- html.gsub!(/^\s*\[([^\]]+)\]:\s*<\s*(data:image\/[^>]+)\s*>\s*$/) do
150
- image_refs[$1] = $2.strip
151
- ""
152
- end
153
-
154
- html.gsub!(/(?<!\\)!\[([^\]]*)\]\[([^\]]+)\]/) do
155
- if (data_url = image_refs[$2])
156
- convert_data_image_to_attachment(data_url, $1)
157
- else
158
- "![#{$1}][#{$2}]"
159
- end
160
- end
161
-
162
- html.gsub!(/(?<!\\)!\[([^\]]*)\]\((data:image\/[^)]+)\)/) do
163
- convert_data_image_to_attachment($2, $1)
164
- end
165
- html.gsub!(/(?<!\\)\[([^\]]+)\]\(([^)]+)\)/) do
166
- "<a href=\"#{$2}\">#{$1}</a>"
167
- end
168
- html.gsub!(/(?<!\\)(\*\*|__)(.+?)\1/m) do
169
- "<strong>#{$2}</strong>"
170
- end
171
- html.gsub!(/\\([\\*_\[\]()!#~+\-])/, '\\1')
172
- html.gsub!(/\\\\/, "\\")
173
- html.strip!
174
- html
144
+ MarkdownConverter.markdown_to_html(text, image_refs)
175
145
  end
176
146
 
177
147
  def html_links_to_markdown(text)
178
- return "" if text.nil?
179
- markdown = text.dup
180
- placeholders = {}
181
- index = 0
182
- markdown.gsub!(%r{<table\b[^>]*>.*?</table>}im) do |match|
183
- token = "__TABLE#{index}__"; index += 1
184
- placeholders[token] = html_table_to_markdown(match)
185
- token
186
- end
187
- markdown.gsub!(%r{<action-text-attachment ([^>]+)>(?:</action-text-attachment>)?}) do |match|
188
- attrs = Hash[$1.scan(/(\S+?)="([^"]*)"/)]
189
- sgid = attrs["sgid"]
190
- caption = attrs["caption"] || ""
191
- if (blob = GlobalID::Locator.locate_signed(sgid, for: "attachable"))
192
- data = Base64.strict_encode64(blob.download)
193
- token = "__IMG#{index}__"; index += 1
194
- placeholders[token] = "![#{caption}](data:#{blob.content_type};base64,#{data})"
195
- token
196
- else
197
- ""
198
- end
199
- end
200
- markdown.gsub!(%r{<img [^>]*src=["'](/rails/active_storage/blobs/[^"']+)["'][^>]*alt=["']([^"']*)["'][^>]*>}) do |match|
201
- blob_path = $1
202
- alt_text = $2
203
- if blob_path =~ %r{/rails/active_storage/blobs/(?:redirect|proxy)/([^/]+)/}
204
- signed_id = $1
205
- begin
206
- blob = ActiveStorage::Blob.find_signed(signed_id)
207
- data = Base64.strict_encode64(blob.download)
208
- token = "__IMG#{index}__"; index += 1
209
- placeholders[token] = "![#{alt_text}](data:#{blob.content_type};base64,#{data})"
210
- token
211
- rescue
212
- match
213
- end
214
- else
215
- match
216
- end
217
- end
218
- markdown.gsub!(/<img [^>]*src=['"](data:[^'"]+)['"][^>]*alt=['"]([^'"]*)['"][^>]*>/) do
219
- token = "__IMG#{index}__"; index += 1
220
- placeholders[token] = "![#{$2}](#{$1})"
221
- token
222
- end
223
- markdown.gsub!(/<img [^>]*alt=['"]([^'"]*)['"][^>]*src=['"](data:[^'"]+)['"][^>]*>/) do
224
- token = "__IMG#{index}__"; index += 1
225
- placeholders[token] = "![#{$1}](#{$2})"
226
- token
227
- end
228
- markdown.gsub!(/<a [^>]*href=['"]([^'"]+)['"][^>]*>(.*?)<\/a>/m) do
229
- inner = ActionView::Base.full_sanitizer.sanitize($2)
230
- token = "__LINK#{index}__"; index += 1
231
- placeholders[token] = "[#{inner}](#{$1})"
232
- token
233
- end
234
- markdown.gsub!(/<(strong|b)(?:\s+[^>]*)?>(.*?)<\/\1>/im) do
235
- token = "__BOLD#{index}__"; index += 1
236
- placeholders[token] = "**#{$2.strip}**"
237
- token
238
- end
239
- markdown.gsub!(/([\\*\[\]()!#~+\-])/) { "\\#{$1}" }
240
- placeholders.each { |k, v| markdown.gsub!(k, v) }
241
- markdown.gsub!(/<[^>]+>/, "")
242
- markdown
243
- end
244
-
245
- private
246
-
247
- def markdown_table_block?(text)
248
- lines = text.to_s.strip.split("\n")
249
- return false if lines.length < 2
250
-
251
- header_line = lines[0]
252
- alignment_line = lines[1]
253
- return false unless header_line.match?(/\A\|.*\|\z/)
254
- return false unless alignment_line.match?(/\A\|[ \-:\|]+\|\z/)
255
-
256
- true
257
- end
258
-
259
- def convert_data_image_to_attachment(data_url, alt)
260
- if data_url =~ %r{\Adata:(image/[\w.+-]+);base64,(.+)\z}
261
- content_type = Regexp.last_match(1)
262
- data = Base64.decode64(Regexp.last_match(2))
263
- ext = Mime::Type.lookup(content_type).symbol.to_s
264
- filename = "import-#{SecureRandom.hex}.#{ext}"
265
- blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(data), filename: filename, content_type: content_type)
266
- "<img src=\"#{Rails.application.routes.url_helpers.rails_blob_url(blob, only_path: true)}\" alt=\"#{alt}\" />"
267
- else
268
- "<img src=\"#{data_url}\" alt=\"#{alt}\" />"
269
- end
270
- end
271
-
272
- def html_table_to_markdown(table_html)
273
- fragment = Nokogiri::HTML::DocumentFragment.parse(table_html)
274
- table = fragment.at_css("table")
275
- return "" unless table
276
-
277
- header_row = table.at_css("thead tr") || table.css("tr").first
278
- return "" unless header_row
279
-
280
- header_cells = header_row.css("th,td")
281
- headers = header_cells.map { |cell| escape_markdown_table_cell(html_links_to_markdown(cell.inner_html).strip) }
282
-
283
- alignments = header_cells.map { |cell| alignment_from_html_cell(cell) }
284
-
285
- body_rows = table.css("tbody tr")
286
- if body_rows.empty?
287
- all_rows = table.css("tr")
288
- body_rows = all_rows.drop(1)
289
- end
290
-
291
- body_lines = body_rows.map do |row|
292
- cells = row.css("th,td").map { |cell| escape_markdown_table_cell(html_links_to_markdown(cell.inner_html).strip) }
293
- normalized = normalize_row_cells(cells, headers.length)
294
- "| #{normalized.join(' | ')} |"
295
- end
296
-
297
- alignment_cells = normalize_row_cells(alignments, headers.length).map { |align| alignment_to_markdown(align) }
298
- header_line = "| #{headers.map(&:strip).join(' | ')} |"
299
- alignment_line = "| #{alignment_cells.join(' | ')} |"
300
-
301
- ([ header_line, alignment_line ] + body_lines).join("\n")
302
- end
303
-
304
- def escape_markdown_table_cell(text)
305
- text.to_s.gsub(/(?<!\\)\|/, '\\|')
306
- end
307
-
308
- def alignment_from_html_cell(cell)
309
- style = cell["style"].to_s
310
- align = cell["align"].to_s
311
- case
312
- when style =~ /text-align\s*:\s*center/i || align =~ /center/i
313
- :center
314
- when style =~ /text-align\s*:\s*right/i || align =~ /right/i
315
- :right
316
- when style =~ /text-align\s*:\s*left/i || align =~ /left/i
317
- :left
318
- else
319
- nil
320
- end
321
- end
322
-
323
- def alignment_to_markdown(alignment)
324
- case alignment
325
- when :center
326
- ":---:"
327
- when :right
328
- "---:"
329
- when :left
330
- ":---"
331
- else
332
- "---"
333
- end
334
- end
335
-
336
- def normalize_row_cells(cells, expected_length)
337
- values = cells.dup
338
- values = values.first(expected_length)
339
- values.fill("", values.length...expected_length)
340
- values
148
+ MarkdownConverter.html_to_markdown(text)
341
149
  end
342
150
  end
343
151
  end
@@ -12,7 +12,6 @@ import "./modules/plans_menu"
12
12
  import "./modules/inbox_panel"
13
13
  import "./modules/creative_guide"
14
14
  import "./modules/share_modal"
15
- import "./modules/share_user_popup"
16
15
  import "./modules/creative_row_editor"
17
16
  import "./modules/slide_view"
18
17
 
@@ -1,6 +1,7 @@
1
1
  import { LitElement, html, nothing } from "lit";
2
2
  import DOMPurify from "dompurify";
3
3
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
4
+ import { parseEmojis } from "../utils/emoji_parser";
4
5
 
5
6
  const BULLET_STARTING_LEVEL = 3;
6
7
 
@@ -303,7 +304,7 @@ class CreativeTreeRow extends LitElement {
303
304
 
304
305
  // If no children, render as div instead of heading to avoid large font size
305
306
  if (!this.hasChildren) {
306
- return html`<div class=${headingClass}>${content}${indicator}</div>`;
307
+ return html`<div class="${headingClass} creative-childless">${content}${indicator}</div>`;
307
308
  }
308
309
 
309
310
  // We wrap content in heading tags for semantics
@@ -361,7 +362,7 @@ class CreativeTreeRow extends LitElement {
361
362
  emojiString = rootStyle.getPropertyValue('--creative-loading-emojis').replace(/"/g, '').trim();
362
363
  }
363
364
 
364
- const emojis = emojiString ? emojiString.split(',').map(e => e.trim()) : ['🎨', '💡', '🚀', '✨', '🧩', '🎲'];
365
+ const emojis = parseEmojis(emojiString);
365
366
 
366
367
  let emojiIndex = 0;
367
368
  let frame = 0;
@@ -1,18 +1,132 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { renderCommentMarkdown } from '../lib/utils/markdown'
3
+ import CommonPopup from '../lib/common_popup'
4
+
5
+ // Global tracker: persists streaming state across Turbo replacements
6
+ // (each replacement creates a new controller instance, losing instance state)
7
+ if (!window._streamingCommentIds) window._streamingCommentIds = new Set()
3
8
 
4
9
  // Connects to data-controller="comment"
5
10
  export default class extends Controller {
6
- static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls"]
11
+ static targets = ["ownerButton", "deleteButton", "approveButton", "actionApproveControls", "reviewButton", "replaceButton"]
12
+
13
+ get _commentId() {
14
+ return this.element.dataset.commentId
15
+ }
16
+
17
+ get _isStreaming() {
18
+ return window._streamingCommentIds.has(this._commentId)
19
+ }
20
+
21
+ set _isStreaming(val) {
22
+ if (val) {
23
+ window._streamingCommentIds.add(this._commentId)
24
+ } else {
25
+ window._streamingCommentIds.delete(this._commentId)
26
+ }
27
+ }
28
+
29
+ // --- Streaming state helpers ---
30
+
31
+ get _isAiComment() {
32
+ return this.element.dataset.aiUser === 'true'
33
+ }
34
+
35
+ // Server explicitly marked this broadcast as streaming (true) or done (false).
36
+ // When no streaming local is provided the partial defaults to "false".
37
+ get _serverStreaming() {
38
+ return this.element.dataset.streaming === 'true'
39
+ }
40
+
41
+ // The server sent streaming: false explicitly — stream just ended.
42
+ get _serverStreamingDone() {
43
+ return this.element.dataset.streaming === 'false'
44
+ }
45
+
46
+ // Should we treat this comment as currently streaming?
47
+ // True when the server says so OR the global Set remembers it,
48
+ // but NOT if the server explicitly signalled completion.
49
+ _shouldStream(text) {
50
+ if (!this._isAiComment || !text.trim()) return false
51
+ if (this._serverStreamingDone) return false
52
+ return this._isStreaming || this._serverStreaming
53
+ }
54
+
55
+ _appendStreamingCursor(el) {
56
+ const cursor = document.createElement('span')
57
+ cursor.className = 'streaming-cursor'
58
+ cursor.textContent = '▍'
59
+ let target = el
60
+ while (target.lastElementChild && target.lastElementChild.tagName !== 'BR') {
61
+ target = target.lastElementChild
62
+ }
63
+ target.appendChild(cursor)
64
+ }
65
+
66
+ _resetStreamingTimeout() {
67
+ if (this._streamingTimeout) clearTimeout(this._streamingTimeout)
68
+ this._streamingTimeout = setTimeout(() => {
69
+ const currentEl = this.element?.querySelector('.comment-content')
70
+ if (currentEl) {
71
+ currentEl.dataset.rendering = 'true'
72
+ try {
73
+ currentEl.classList.remove('streaming')
74
+ const c = currentEl.querySelector('.streaming-cursor')
75
+ if (c) c.remove()
76
+ } finally {
77
+ requestAnimationFrame(() => { currentEl.dataset.rendering = 'false' })
78
+ }
79
+ }
80
+ this._isStreaming = false
81
+ }, 10000)
82
+ }
83
+
84
+ _cleanupStreaming() {
85
+ this._isStreaming = false
86
+ if (this._streamingTimeout) {
87
+ clearTimeout(this._streamingTimeout)
88
+ this._streamingTimeout = null
89
+ }
90
+ }
7
91
 
8
92
  connect() {
93
+ if (!this._streamingTimeout) this._streamingTimeout = null
9
94
  const contentElement = this.element.querySelector('.comment-content')
10
95
  if (contentElement && contentElement.dataset.rendered !== 'true') {
11
- const text = contentElement.textContent || ''
12
- contentElement.innerHTML = renderCommentMarkdown(text)
13
- contentElement.dataset.rendered = 'true'
96
+ contentElement.dataset.rendering = 'true'
97
+ try {
98
+ const text = contentElement.textContent || ''
99
+ if (this._isAiComment && text.trim() === '...') {
100
+ // Streaming placeholder — show animated dots
101
+ this._isStreaming = true
102
+ contentElement.innerHTML = '<span class="streaming-dots"><span>.</span><span>.</span><span>.</span></span>'
103
+ contentElement.classList.add('streaming')
104
+ } else if (this._shouldStream(text)) {
105
+ // Streaming content arrived — render markdown with cursor
106
+ this._isStreaming = true
107
+ contentElement.innerHTML = renderCommentMarkdown(text)
108
+ this._appendStreamingCursor(contentElement)
109
+ contentElement.classList.add('streaming')
110
+ this._resetStreamingTimeout()
111
+ } else {
112
+ contentElement.innerHTML = renderCommentMarkdown(text)
113
+ contentElement.classList.remove('streaming')
114
+ if (this._isStreaming) this._cleanupStreaming()
115
+ }
116
+ contentElement.dataset.rendered = 'true'
117
+ } finally {
118
+ requestAnimationFrame(() => { contentElement.dataset.rendering = 'false' })
119
+ }
14
120
  }
15
121
 
122
+ // Text selection quote support
123
+ this.handleMouseUp = this.handleMouseUp.bind(this)
124
+ this.element.addEventListener('mouseup', this.handleMouseUp)
125
+
126
+ // Bound handlers for review/replace buttons (stored for cleanup)
127
+ this._boundReviewClick = this._onReviewClick.bind(this)
128
+ this._boundReplaceClick = this._onReplaceClick.bind(this)
129
+
16
130
  this.currentUserId = document.body.dataset.currentUserId
17
131
  const commentAuthorId = this.element.dataset.userId
18
132
  const creativeOwnerId = this.element.dataset.creativeOwnerId
@@ -108,6 +222,197 @@ export default class extends Controller {
108
222
  }
109
223
  }
110
224
 
225
+ disconnect() {
226
+ if (this._streamingTimeout) {
227
+ clearTimeout(this._streamingTimeout)
228
+ this._streamingTimeout = null
229
+ }
230
+ this.element.removeEventListener('mouseup', this.handleMouseUp)
231
+ this._removeSelectionChangeListener()
232
+ this.hideReviewPopup()
233
+ if (this._reviewPopupEl) {
234
+ this._reviewPopupEl.remove()
235
+ this._reviewPopupEl = null
236
+ this._reviewPopup = null
237
+ }
238
+ }
239
+
240
+ handleMouseUp() {
241
+ // Small delay to let selection finalize
242
+ requestAnimationFrame(() => {
243
+ this.hideReviewPopup()
244
+
245
+ const selection = window.getSelection()
246
+ if (!selection || selection.isCollapsed) return
247
+
248
+ const selectedText = selection.toString().trim()
249
+ if (!selectedText) return
250
+
251
+ // Ensure selection is within this comment's content
252
+ const contentEl = this.element.querySelector('.comment-content')
253
+ if (!contentEl) return
254
+
255
+ const range = selection.getRangeAt(0)
256
+ if (!contentEl.contains(range.commonAncestorContainer)) return
257
+
258
+ // Show review popup below the selection using CommonPopup
259
+ const rect = range.getBoundingClientRect()
260
+ this.showReviewPopup(rect, selectedText)
261
+ })
262
+ }
263
+
264
+ showReviewPopup(anchorRect, selectedText) {
265
+ // Create or reuse popup element
266
+ if (!this._reviewPopupEl) {
267
+ const el = document.createElement('div')
268
+ el.className = 'common-popup comment-review-popup'
269
+ el.style.display = 'none'
270
+ el.style.padding = '0.3em'
271
+
272
+ const list = document.createElement('ul')
273
+ list.style.listStyle = 'none'
274
+ list.style.margin = '0'
275
+ list.style.padding = '0'
276
+ el.appendChild(list)
277
+
278
+ const commentsPopup = this.element.closest('#comments-popup')
279
+ if (commentsPopup) {
280
+ commentsPopup.appendChild(el)
281
+ } else {
282
+ document.body.appendChild(el)
283
+ }
284
+ this._reviewPopupEl = el
285
+ }
286
+
287
+ const reviewLabel = this.element.closest('#comments-popup')?.dataset?.reviewButtonText || 'Review'
288
+
289
+ this._reviewPopup = new CommonPopup(this._reviewPopupEl, {
290
+ onSelect: () => {
291
+ const commentId = this.element.dataset.commentId
292
+ const formController = this.findFormController()
293
+ if (formController) {
294
+ formController.quoteComment(commentId, selectedText)
295
+ }
296
+ window.getSelection().removeAllRanges()
297
+ this.hideReviewPopup()
298
+ },
299
+ renderItem: () => reviewLabel,
300
+ })
301
+
302
+ // Position below the selection end
303
+ const belowRect = {
304
+ left: anchorRect.left,
305
+ right: anchorRect.right,
306
+ top: anchorRect.bottom,
307
+ bottom: anchorRect.bottom + 4,
308
+ width: anchorRect.width,
309
+ }
310
+
311
+ this._reviewPopup.setItems([{ label: reviewLabel }])
312
+ this._reviewPopup.showAt(belowRect)
313
+ }
314
+
315
+ hideReviewPopup() {
316
+ if (this._reviewPopup) {
317
+ this._reviewPopup.hide()
318
+ }
319
+ }
320
+
321
+ findFormController() {
322
+ const popup = this.element.closest('#comments-popup')
323
+ if (!popup) return null
324
+ return this.application.getControllerForElementAndIdentifier(popup, 'comments--form')
325
+ }
326
+
327
+ reviewButtonTargetConnected(button) {
328
+ button.addEventListener('click', this._boundReviewClick)
329
+ }
330
+
331
+ reviewButtonTargetDisconnected(button) {
332
+ button.removeEventListener('click', this._boundReviewClick)
333
+ }
334
+
335
+ replaceButtonTargetConnected(button) {
336
+ button.addEventListener('click', this._boundReplaceClick)
337
+ // Only listen for selectionchange when a replace button exists
338
+ this._addSelectionChangeListener()
339
+ }
340
+
341
+ replaceButtonTargetDisconnected(button) {
342
+ button.removeEventListener('click', this._boundReplaceClick)
343
+ if (!this.hasReplaceButtonTarget) {
344
+ this._removeSelectionChangeListener()
345
+ }
346
+ }
347
+
348
+ _onReviewClick(event) {
349
+ event.preventDefault()
350
+ event.stopPropagation()
351
+ const commentId = this.element.dataset.commentId
352
+ const contentEl = this.element.querySelector('.comment-content')
353
+ const fullText = contentEl ? contentEl.textContent.trim() : ''
354
+ const formController = this.findFormController()
355
+ if (formController && fullText) {
356
+ formController.quoteComment(commentId, fullText)
357
+ }
358
+ }
359
+
360
+ _onReplaceClick(event) {
361
+ event.preventDefault()
362
+ event.stopPropagation()
363
+ const selectedText = this._getSelectedTextInContent()
364
+ if (!selectedText) return
365
+
366
+ const commentId = this.element.dataset.commentId
367
+ const formController = this.findFormController()
368
+ if (formController) {
369
+ formController.quoteComment(commentId, selectedText)
370
+ }
371
+ window.getSelection().removeAllRanges()
372
+ this._updateReplaceButton()
373
+ }
374
+
375
+ _getSelectedTextInContent() {
376
+ const selection = window.getSelection()
377
+ if (!selection || selection.isCollapsed) return null
378
+
379
+ const text = selection.toString().trim()
380
+ if (!text) return null
381
+
382
+ const contentEl = this.element.querySelector('.comment-content')
383
+ if (!contentEl) return null
384
+
385
+ const range = selection.getRangeAt(0)
386
+ if (!contentEl.contains(range.commonAncestorContainer)) return null
387
+
388
+ return text
389
+ }
390
+
391
+ _addSelectionChangeListener() {
392
+ if (this._selectionChangeActive) return
393
+ this._handleSelectionChange = this._handleSelectionChange.bind(this)
394
+ document.addEventListener('selectionchange', this._handleSelectionChange)
395
+ this._selectionChangeActive = true
396
+ }
397
+
398
+ _removeSelectionChangeListener() {
399
+ if (!this._selectionChangeActive) return
400
+ document.removeEventListener('selectionchange', this._handleSelectionChange)
401
+ this._selectionChangeActive = false
402
+ }
403
+
404
+ _handleSelectionChange() {
405
+ this._updateReplaceButton()
406
+ }
407
+
408
+ _updateReplaceButton() {
409
+ if (!this.hasReplaceButtonTarget) return
410
+ const hasSelection = !!this._getSelectedTextInContent()
411
+ this.replaceButtonTargets.forEach((btn) => {
412
+ btn.disabled = !hasSelection
413
+ })
414
+ }
415
+
111
416
  updateReactionsUI(reactionsData) {
112
417
  let reactionsContainer = this.element.querySelector('.comment-reactions')
113
418