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,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import { renderMarkdownInContainer } from '../../lib/utils/markdown'
3
+ import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
3
4
 
4
5
  export default class extends Controller {
5
6
  static targets = [
@@ -14,6 +15,10 @@ export default class extends Controller {
14
15
  'imageInput',
15
16
  'imageButton',
16
17
  'attachmentList',
18
+ 'quotedCommentId',
19
+ 'quotedText',
20
+ 'quoteIndicator',
21
+ 'quoteIndicatorText',
17
22
  ]
18
23
 
19
24
  connect() {
@@ -53,6 +58,8 @@ export default class extends Controller {
53
58
  this.imageInputTarget?.addEventListener('change', this.handleImageChange)
54
59
  this.textareaTarget.addEventListener('dragover', this.handleDragOver)
55
60
  this.textareaTarget.addEventListener('drop', this.handleDrop)
61
+ this.handlePaste = this.handlePaste.bind(this)
62
+ this.textareaTarget.addEventListener('paste', this.handlePaste)
56
63
 
57
64
 
58
65
  this.recognition = null
@@ -97,6 +104,7 @@ export default class extends Controller {
97
104
  this.imageInputTarget?.removeEventListener('change', this.handleImageChange)
98
105
  this.textareaTarget.removeEventListener('dragover', this.handleDragOver)
99
106
  this.textareaTarget.removeEventListener('drop', this.handleDrop)
107
+ this.textareaTarget.removeEventListener('paste', this.handlePaste)
100
108
  this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
101
109
  }
102
110
 
@@ -154,6 +162,7 @@ export default class extends Controller {
154
162
  if (this.cancelTarget) this.cancelTarget.style.display = 'none'
155
163
  this.presenceController?.clearManualTypingMessage()
156
164
  this.clearImageAttachments()
165
+ this.cancelQuote()
157
166
  }
158
167
 
159
168
  setSendingState(isSending) {
@@ -420,6 +429,31 @@ export default class extends Controller {
420
429
  this.updateAttachmentList()
421
430
  }
422
431
 
432
+ handlePaste(event) {
433
+ const clipboardData = event.clipboardData
434
+ if (!clipboardData) return
435
+
436
+ const text = clipboardData.getData('text/plain') || clipboardData.getData('text')
437
+ if (!text) return
438
+
439
+ const { changed, result } = wrapHtmlInCodeBlocks(text)
440
+ if (!changed) return
441
+
442
+ event.preventDefault()
443
+
444
+ const textarea = this.textareaTarget
445
+ const start = textarea.selectionStart
446
+ const end = textarea.selectionEnd
447
+ const before = textarea.value.substring(0, start)
448
+ const after = textarea.value.substring(end)
449
+ // Ensure blank line before code fence if there's preceding text
450
+ const separator = before.length > 0 && !before.endsWith('\n') ? '\n' : ''
451
+ textarea.value = before + separator + result + after
452
+ const cursorPos = start + separator.length + result.length
453
+ textarea.setSelectionRange(cursorPos, cursorPos)
454
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
455
+ }
456
+
423
457
  handleDragOver(event) {
424
458
  if (this.hasImageFromDataTransfer(event.dataTransfer)) {
425
459
  event.preventDefault()
@@ -499,6 +533,24 @@ export default class extends Controller {
499
533
  })
500
534
  }
501
535
 
536
+ quoteComment(commentId, selectedText) {
537
+ if (!commentId || !selectedText) return
538
+ this.quotedCommentIdTarget.value = commentId
539
+ this.quotedTextTarget.value = selectedText
540
+ this.quoteIndicatorTarget.style.display = ''
541
+ this.quoteIndicatorTextTarget.textContent = selectedText.length > 80
542
+ ? selectedText.substring(0, 80) + '…'
543
+ : selectedText
544
+ this.focusTextarea()
545
+ }
546
+
547
+ cancelQuote() {
548
+ this.quotedCommentIdTarget.value = ''
549
+ this.quotedTextTarget.value = ''
550
+ this.quoteIndicatorTarget.style.display = 'none'
551
+ this.quoteIndicatorTextTarget.textContent = ''
552
+ }
553
+
502
554
  renderCommentHtml(html, { replaceExisting = false } = {}) {
503
555
  const listElement = document.getElementById('comments-list')
504
556
  if (!listElement || !html) return
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import { createSubscription } from '../../services/cable'
3
3
 
4
4
  const TYPING_TIMEOUT = 3000
5
+ const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
5
6
 
6
7
  export default class extends Controller {
7
8
  static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox']
@@ -158,8 +159,20 @@ export default class extends Controller {
158
159
  }
159
160
  if (status === 'thinking' || status === 'streaming') {
160
161
  this.typingUsers[id] = name
162
+ // Safety timeout: auto-remove if no heartbeat within AGENT_STATUS_TIMEOUT
163
+ if (!this.agentStatusTimers) this.agentStatusTimers = {}
164
+ if (this.agentStatusTimers[id]) clearTimeout(this.agentStatusTimers[id])
165
+ this.agentStatusTimers[id] = setTimeout(() => {
166
+ delete this.typingUsers[id]
167
+ delete this.agentStatusTimers[id]
168
+ this.renderTypingIndicator()
169
+ }, AGENT_STATUS_TIMEOUT)
161
170
  } else {
162
171
  delete this.typingUsers[id]
172
+ if (this.agentStatusTimers?.[id]) {
173
+ clearTimeout(this.agentStatusTimers[id])
174
+ delete this.agentStatusTimers[id]
175
+ }
163
176
  }
164
177
  this.renderTypingIndicator()
165
178
  }
@@ -1,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
3
+ import { parseEmojis } from '../../utils/emoji_parser'
3
4
 
4
5
  export default class extends Controller {
5
6
  static values = {
@@ -121,7 +122,7 @@ export default class extends Controller {
121
122
  emojiString = rootStyle.getPropertyValue('--creative-loading-emojis').replace(/"/g, '').trim()
122
123
  }
123
124
 
124
- const emojis = emojiString ? emojiString.split(',').map(e => e.trim()) : ['🎨', '💡', '🚀', '✨', '🧩', '🎲']
125
+ const emojis = parseEmojis(emojiString)
125
126
 
126
127
  let emojiIndex = 0
127
128
  let frame = 0 // 0: ..., 1: ..E, 2: .E., 3: E..
@@ -6,7 +6,9 @@ export default class extends CommonPopupController {
6
6
 
7
7
  connect() {
8
8
  super.connect()
9
- this.inputTarget.addEventListener('input', this.search.bind(this))
9
+ this._debounceTimer = null
10
+ this._searchToken = 0
11
+ this.inputTarget.addEventListener('input', this._debouncedSearch.bind(this))
10
12
  this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
11
13
  this.closeTarget.addEventListener('click', () => this.close())
12
14
 
@@ -14,6 +16,14 @@ export default class extends CommonPopupController {
14
16
  this.open = this.open.bind(this)
15
17
  }
16
18
 
19
+ disconnect() {
20
+ if (this._debounceTimer) {
21
+ clearTimeout(this._debounceTimer)
22
+ this._debounceTimer = null
23
+ }
24
+ super.disconnect()
25
+ }
26
+
17
27
  open(anchorRect, onSelectCallback, onCloseCallback) {
18
28
  this.onSelectCallback = onSelectCallback
19
29
  this.onCloseCallback = onCloseCallback
@@ -27,6 +37,10 @@ export default class extends CommonPopupController {
27
37
  }
28
38
 
29
39
  close() {
40
+ if (this._debounceTimer) {
41
+ clearTimeout(this._debounceTimer)
42
+ this._debounceTimer = null
43
+ }
30
44
  // super.close() calls popup.hide(), which triggers dispatchClose,
31
45
  // where we now handle the callback. So we just need to call super.close().
32
46
  super.close()
@@ -42,21 +56,33 @@ export default class extends CommonPopupController {
42
56
  }
43
57
  }
44
58
 
59
+ _debouncedSearch() {
60
+ if (this._debounceTimer) clearTimeout(this._debounceTimer)
61
+ this._debounceTimer = setTimeout(() => this.search(), 300)
62
+ }
63
+
45
64
  search() {
46
65
  const query = this.inputTarget.value.trim()
47
- if (!query) {
66
+ if (query.length < 3) {
67
+ this._searchToken++
48
68
  this.setItems([])
49
69
  return
50
70
  }
51
71
 
72
+ const token = ++this._searchToken
52
73
  creativesApi.search(query, { simple: true })
53
74
  .then((results) => {
75
+ // Discard stale responses if input changed since this request
76
+ if (token !== this._searchToken) return
77
+
54
78
  const items = Array.isArray(results)
55
79
  ? results.map((result) => ({ id: result.id, label: result.description }))
56
80
  : []
57
81
  this.setItems(items)
58
82
  })
59
- .catch(() => this.setItems([]))
83
+ .catch(() => {
84
+ if (token === this._searchToken) this.setItems([])
85
+ })
60
86
  }
61
87
 
62
88
  // Override select to invoke callback
@@ -0,0 +1,201 @@
1
+ import { wrapHtmlInCodeBlocks } from '../html_code_block_wrapper'
2
+
3
+ describe('wrapHtmlInCodeBlocks', () => {
4
+ // --- No change cases ---
5
+
6
+ test('returns unchanged for null/undefined/empty', () => {
7
+ expect(wrapHtmlInCodeBlocks(null)).toEqual({ changed: false, result: null })
8
+ expect(wrapHtmlInCodeBlocks(undefined)).toEqual({ changed: false, result: undefined })
9
+ expect(wrapHtmlInCodeBlocks('')).toEqual({ changed: false, result: '' })
10
+ })
11
+
12
+ test('returns unchanged for plain text', () => {
13
+ const text = 'Hello world, no HTML here'
14
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
15
+ })
16
+
17
+ test('returns unchanged for text with angle brackets but no tags', () => {
18
+ expect(wrapHtmlInCodeBlocks('5 > 3 and 2 < 4')).toEqual({
19
+ changed: false,
20
+ result: '5 > 3 and 2 < 4',
21
+ })
22
+ })
23
+
24
+ test('returns unchanged for already code-blocked content', () => {
25
+ const text = '```html\n<div>hello</div>\n```'
26
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
27
+ })
28
+
29
+ test('returns unchanged for code block with leading whitespace', () => {
30
+ const text = ' ```\n<div>hello</div>\n```'
31
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
32
+ })
33
+
34
+ // --- Single HTML block ---
35
+
36
+ test('wraps a single div', () => {
37
+ const { changed, result } = wrapHtmlInCodeBlocks('<div>hello</div>')
38
+ expect(changed).toBe(true)
39
+ expect(result).toBe('```html\n<div>hello</div>\n```')
40
+ })
41
+
42
+ test('wraps a div with attributes', () => {
43
+ const { changed, result } = wrapHtmlInCodeBlocks('<div class="test" id="foo">content</div>')
44
+ expect(changed).toBe(true)
45
+ expect(result).toBe('```html\n<div class="test" id="foo">content</div>\n```')
46
+ })
47
+
48
+ test('wraps nested HTML', () => {
49
+ const html = '<div><p>hello</p><span>world</span></div>'
50
+ const { changed, result } = wrapHtmlInCodeBlocks(html)
51
+ expect(changed).toBe(true)
52
+ expect(result).toBe('```html\n' + html + '\n```')
53
+ })
54
+
55
+ test('wraps self-closing tag', () => {
56
+ const { changed, result } = wrapHtmlInCodeBlocks('<br/>')
57
+ expect(changed).toBe(true)
58
+ expect(result).toBe('```html\n<br/>\n```')
59
+ })
60
+
61
+ test('wraps self-closing tag with space', () => {
62
+ const { changed, result } = wrapHtmlInCodeBlocks('<img src="test.png" />')
63
+ expect(changed).toBe(true)
64
+ expect(result).toBe('```html\n<img src="test.png" />\n```')
65
+ })
66
+
67
+ // --- HTML with surrounding text ---
68
+
69
+ test('wraps HTML with text before', () => {
70
+ const { changed, result } = wrapHtmlInCodeBlocks('여기 봐봐 <div>hello</div>')
71
+ expect(changed).toBe(true)
72
+ expect(result).toBe('여기 봐봐 \n\n```html\n<div>hello</div>\n```')
73
+ })
74
+
75
+ test('wraps HTML with text after', () => {
76
+ const { changed, result } = wrapHtmlInCodeBlocks('<div>hello</div> 이거 뭐야?')
77
+ expect(changed).toBe(true)
78
+ expect(result).toBe('```html\n<div>hello</div>\n```\n\n 이거 뭐야?')
79
+ })
80
+
81
+ test('wraps HTML with text before and after', () => {
82
+ const { changed, result } = wrapHtmlInCodeBlocks('여기 봐봐 <div>hello</div> 이거 뭐야?')
83
+ expect(changed).toBe(true)
84
+ expect(result).toBe('여기 봐봐 \n\n```html\n<div>hello</div>\n```\n\n 이거 뭐야?')
85
+ })
86
+
87
+ // --- Multiple HTML blocks ---
88
+
89
+ test('wraps multiple separate HTML blocks', () => {
90
+ const { changed, result } = wrapHtmlInCodeBlocks('<div>first</div> text <span>second</span>')
91
+ expect(changed).toBe(true)
92
+ expect(result).toBe('```html\n<div>first</div>\n```\n\n text \n\n```html\n<span>second</span>\n```')
93
+ })
94
+
95
+ // --- Complex real-world HTML ---
96
+
97
+ test('wraps complex HTML with attributes and nesting', () => {
98
+ const html = '<div id="llm-model-suggestions" class="common-popup mention-popup" style="display: none;"><ul class="common-popup-list"><li class="common-popup-item"><div class="mention-item">gemini-2.5-flash</div></li></ul></div>'
99
+ const { changed, result } = wrapHtmlInCodeBlocks(html)
100
+ expect(changed).toBe(true)
101
+ expect(result).toBe('```html\n' + html + '\n```')
102
+ })
103
+
104
+ test('wraps complex HTML with surrounding text', () => {
105
+ const html = '<div id="test" class="popup" style="display: none;"><ul><li>item</li></ul></div>'
106
+ const input = '이 코드를 봐줘 ' + html + ' 이게 맞아?'
107
+ const { changed, result } = wrapHtmlInCodeBlocks(input)
108
+ expect(changed).toBe(true)
109
+ expect(result).toBe('이 코드를 봐줘 \n\n```html\n' + html + '\n```\n\n 이게 맞아?')
110
+ })
111
+
112
+ // --- Multiline HTML ---
113
+
114
+ test('wraps multiline HTML', () => {
115
+ const html = '<div>\n <p>hello</p>\n <p>world</p>\n</div>'
116
+ const { changed, result } = wrapHtmlInCodeBlocks(html)
117
+ expect(changed).toBe(true)
118
+ expect(result).toBe('```html\n' + html + '\n```')
119
+ })
120
+
121
+ // --- Edge cases ---
122
+
123
+ test('handles single unclosed tag (no match)', () => {
124
+ const text = '<div>hello but no closing tag'
125
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
126
+ })
127
+
128
+ test('handles mismatched tags (no match)', () => {
129
+ const text = '<div>hello</span>'
130
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
131
+ })
132
+
133
+ test('handles HTML-like content in markdown', () => {
134
+ const { changed, result } = wrapHtmlInCodeBlocks('<b>bold text</b>')
135
+ expect(changed).toBe(true)
136
+ expect(result).toBe('```html\n<b>bold text</b>\n```')
137
+ })
138
+
139
+ test('handles table HTML', () => {
140
+ const html = '<table><tr><td>cell</td></tr></table>'
141
+ const { changed, result } = wrapHtmlInCodeBlocks(html)
142
+ expect(changed).toBe(true)
143
+ expect(result).toBe('```html\n' + html + '\n```')
144
+ })
145
+
146
+ test('handles input self-closing tag', () => {
147
+ const { changed, result } = wrapHtmlInCodeBlocks('<input type="text" />')
148
+ expect(changed).toBe(true)
149
+ expect(result).toBe('```html\n<input type="text" />\n```')
150
+ })
151
+
152
+ test('does not wrap markdown syntax', () => {
153
+ const text = '# heading\n\n**bold** and *italic*'
154
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
155
+ })
156
+
157
+ test('does not wrap template literals with angle brackets', () => {
158
+ const text = 'array.filter(x => x > 0)'
159
+ expect(wrapHtmlInCodeBlocks(text)).toEqual({ changed: false, result: text })
160
+ })
161
+
162
+ // --- Failure scenario: typing text then pasting HTML ---
163
+
164
+ test('text followed by HTML renders as proper markdown code block', () => {
165
+ const { changed, result } = wrapHtmlInCodeBlocks('test <html><h1>hello</h1></html>')
166
+ expect(changed).toBe(true)
167
+ // Must have blank line before code fence for marked to parse correctly
168
+ expect(result).toBe('test \n\n```html\n<html><h1>hello</h1></html>\n```')
169
+ expect(result).toContain('\n\n```html')
170
+ })
171
+
172
+ // --- Double-wrap prevention ---
173
+
174
+ test('does not double-wrap HTML inside existing code blocks', () => {
175
+ const input = '"\n```html\n<button type="button" class="popup-close-btn">×</button>\n```\n"'
176
+ const { changed } = wrapHtmlInCodeBlocks(input)
177
+ expect(changed).toBe(false)
178
+ })
179
+
180
+ test('does not wrap HTML inside code blocks but wraps HTML outside', () => {
181
+ const input = 'before\n```html\n<div>inside</div>\n```\nafter <span>outside</span> end'
182
+ const { changed, result } = wrapHtmlInCodeBlocks(input)
183
+ expect(changed).toBe(true)
184
+ expect(result).toContain('```html\n<div>inside</div>\n```')
185
+ expect(result).toContain('```html\n<span>outside</span>\n```')
186
+ // The inside div should NOT be double-wrapped
187
+ expect(result).not.toContain('```html\n```html')
188
+ })
189
+
190
+ test('mixed text with code block containing HTML is not double-wrapped', () => {
191
+ const input = 'some text\n```\n<button>click</button>\n```\nmore text'
192
+ const { changed } = wrapHtmlInCodeBlocks(input)
193
+ expect(changed).toBe(false)
194
+ })
195
+
196
+ test('text starting with quote then code block is not double-wrapped', () => {
197
+ const input = '"\n```html\n<button id="close-slack-modal" class="popup-close-btn">×</button>\n```\n"'
198
+ const { changed } = wrapHtmlInCodeBlocks(input)
199
+ expect(changed).toBe(false)
200
+ })
201
+ })
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Wraps HTML tag blocks in a string with markdown code blocks.
3
+ * Handles nested tags of the same name correctly.
4
+ *
5
+ * @param {string} text - The input text potentially containing HTML
6
+ * @returns {{ changed: boolean, result: string }}
7
+ */
8
+ export function wrapHtmlInCodeBlocks(text) {
9
+ if (!text) return { changed: false, result: text }
10
+
11
+ // If entire text is a single code block, skip
12
+ if (text.trimStart().startsWith('```') && text.trimEnd().endsWith('```')) {
13
+ return { changed: false, result: text }
14
+ }
15
+
16
+ const segments = extractHtmlSegments(text)
17
+ if (!segments) return { changed: false, result: text }
18
+
19
+ let result = ''
20
+ let lastIndex = 0
21
+
22
+ for (const seg of segments) {
23
+ result += text.slice(lastIndex, seg.start)
24
+ result += '\n\n```html\n' + seg.html + '\n```\n\n'
25
+ lastIndex = seg.end
26
+ }
27
+ result += text.slice(lastIndex)
28
+
29
+ // Trim leading/trailing newlines added by wrapping
30
+ result = result.replace(/^\n+/, '').replace(/\n+$/, '')
31
+
32
+ return { changed: true, result }
33
+ }
34
+
35
+ // Self-closing tag pattern: <tagname ... />
36
+ const SELF_CLOSING_RE = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/>/g
37
+
38
+ // Opening tag pattern: <tagname ...>
39
+ const OPEN_TAG_RE = /<([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g
40
+
41
+ /**
42
+ * Find HTML segments (paired open/close tags or self-closing tags) in text.
43
+ * Returns array of { start, end, html } or null if no HTML found.
44
+ */
45
+ function extractHtmlSegments(text) {
46
+ const segments = []
47
+
48
+ // First pass: find self-closing tags
49
+ const selfClosing = []
50
+ let m
51
+ SELF_CLOSING_RE.lastIndex = 0
52
+ while ((m = SELF_CLOSING_RE.exec(text)) !== null) {
53
+ selfClosing.push({ start: m.index, end: m.index + m[0].length })
54
+ }
55
+
56
+ // Second pass: find paired open/close tags with nesting support
57
+ let i = 0
58
+ while (i < text.length) {
59
+ // Skip positions inside already-found self-closing tags
60
+ if (selfClosing.some((sc) => i >= sc.start && i < sc.end)) {
61
+ const sc = selfClosing.find((sc) => i >= sc.start && i < sc.end)
62
+ i = sc.end
63
+ continue
64
+ }
65
+
66
+ // Look for opening tag at position i
67
+ OPEN_TAG_RE.lastIndex = i
68
+ const openMatch = OPEN_TAG_RE.exec(text)
69
+ if (!openMatch || openMatch.index !== i) {
70
+ i++
71
+ continue
72
+ }
73
+
74
+ // Check if this is actually a self-closing tag
75
+ if (openMatch[0].endsWith('/>')) {
76
+ segments.push({
77
+ start: openMatch.index,
78
+ end: openMatch.index + openMatch[0].length,
79
+ html: openMatch[0],
80
+ })
81
+ i = openMatch.index + openMatch[0].length
82
+ continue
83
+ }
84
+
85
+ const tagName = openMatch[1]
86
+ const searchStart = openMatch.index + openMatch[0].length
87
+
88
+ // Find matching close tag with nesting support
89
+ const closeIndex = findMatchingCloseTag(text, tagName, searchStart)
90
+ if (closeIndex === -1) {
91
+ i++
92
+ continue
93
+ }
94
+
95
+ const closeTag = `</${tagName}>`
96
+ const endPos = closeIndex + closeTag.length
97
+ segments.push({
98
+ start: openMatch.index,
99
+ end: endPos,
100
+ html: text.slice(openMatch.index, endPos),
101
+ })
102
+ i = endPos
103
+ continue
104
+ }
105
+
106
+ // Add self-closing tags that aren't inside paired segments
107
+ for (const sc of selfClosing) {
108
+ if (!segments.some((seg) => sc.start >= seg.start && sc.end <= seg.end)) {
109
+ segments.push({ start: sc.start, end: sc.end, html: text.slice(sc.start, sc.end) })
110
+ }
111
+ }
112
+
113
+ if (segments.length === 0) return null
114
+
115
+ // Sort by position
116
+ segments.sort((a, b) => a.start - b.start)
117
+
118
+ // Filter out segments inside existing markdown code blocks
119
+ const codeBlockRanges = []
120
+ const codeBlockRe = /^```[^\n]*\n[\s\S]*?^```/gm
121
+ let cbMatch
122
+ while ((cbMatch = codeBlockRe.exec(text)) !== null) {
123
+ codeBlockRanges.push({ start: cbMatch.index, end: cbMatch.index + cbMatch[0].length })
124
+ }
125
+ if (codeBlockRanges.length > 0) {
126
+ const filtered = segments.filter(
127
+ (seg) => !codeBlockRanges.some((cb) => seg.start >= cb.start && seg.end <= cb.end)
128
+ )
129
+ return filtered.length > 0 ? filtered : null
130
+ }
131
+
132
+ return segments
133
+ }
134
+
135
+ /**
136
+ * Find the matching close tag for tagName, handling nested same-name tags.
137
+ * Returns the index of the matching </tagName> or -1.
138
+ */
139
+ function findMatchingCloseTag(text, tagName, startFrom) {
140
+ const openPattern = new RegExp(`<${tagName}\\b[^>]*>`, 'gi')
141
+ const closePattern = new RegExp(`</${tagName}>`, 'gi')
142
+
143
+ let depth = 1
144
+ let pos = startFrom
145
+
146
+ while (depth > 0 && pos < text.length) {
147
+ openPattern.lastIndex = pos
148
+ closePattern.lastIndex = pos
149
+
150
+ const nextOpen = openPattern.exec(text)
151
+ const nextClose = closePattern.exec(text)
152
+
153
+ if (!nextClose) return -1 // No closing tag found
154
+
155
+ if (nextOpen && nextOpen.index < nextClose.index && !nextOpen[0].endsWith('/>')) {
156
+ // Nested open tag comes first
157
+ depth++
158
+ pos = nextOpen.index + nextOpen[0].length
159
+ } else {
160
+ // Close tag comes first (or no more open tags)
161
+ depth--
162
+ if (depth === 0) return nextClose.index
163
+ pos = nextClose.index + nextClose[0].length
164
+ }
165
+ }
166
+
167
+ return -1
168
+ }
@@ -11,7 +11,8 @@ export function renderMarkdownInline(html) {
11
11
 
12
12
  export function renderCommentMarkdown(text) {
13
13
  const content = text || ''
14
- const html = content.includes('\n') ? marked.parse(content) : marked.parseInline(content)
14
+ const useBlock = content.includes('\n') || content.includes('```')
15
+ const html = useBlock ? marked.parse(content) : marked.parseInline(content)
15
16
  return DOMPurify.sanitize(html.trim())
16
17
  }
17
18
 
@@ -1751,7 +1751,11 @@ export function initializeCreativeRowEditor() {
1751
1751
  }
1752
1752
  }
1753
1753
  updateProgressInputAvailability(readProgressValue());
1754
- scheduleSave();
1754
+ // Save immediately on checkbox change to prevent losing the last toggle
1755
+ // when the user navigates away before the debounce timer fires.
1756
+ pendingSave = true;
1757
+ clearTimeout(saveTimer);
1758
+ saveForm();
1755
1759
  });
1756
1760
  }
1757
1761
 
@@ -0,0 +1,21 @@
1
+ const DEFAULT_EMOJIS = ['🎨', '💡', '🚀', '✨', '🧩', '🎲']
2
+
3
+ /**
4
+ * Parse a CSS custom property string of emojis into an array.
5
+ * Supports both comma-separated ("🔵, 🔴, 🟡") and concatenated ("🍅🔴🌶️") formats.
6
+ *
7
+ * @param {string} emojiString - raw string from CSS --creative-loading-emojis
8
+ * @returns {string[]} array of individual emoji characters
9
+ */
10
+ export function parseEmojis(emojiString) {
11
+ if (!emojiString) return DEFAULT_EMOJIS
12
+
13
+ let emojis
14
+ if (emojiString.includes(',')) {
15
+ emojis = emojiString.split(',').map(e => e.trim()).filter(e => e)
16
+ } else {
17
+ emojis = [...emojiString.matchAll(/\p{Extended_Pictographic}(?:\u{FE0F}|\u{200D}\p{Extended_Pictographic})*/gu)].map(m => m[0])
18
+ }
19
+
20
+ return emojis.length > 0 ? emojis : DEFAULT_EMOJIS
21
+ }
@@ -23,10 +23,14 @@ module Collavre
23
23
  topic_id: context&.dig("topic", "id")
24
24
  )
25
25
 
26
- # Record task for loop breaker tracking
26
+ # Record task for loop breaker tracking (per-topic, skip user-initiated)
27
27
  creative_id = context&.dig("creative", "id")
28
28
  if creative_id
29
- Orchestration::LoopBreaker.new(context).record_task(creative_id, agent.id)
29
+ from_ai = context&.dig("comment", "from_ai") == true
30
+ topic_id = context&.dig("topic", "id")
31
+ Orchestration::LoopBreaker.new(context).record_task(
32
+ creative_id, agent.id, topic_id: topic_id, triggered_by_user: !from_ai
33
+ )
30
34
  end
31
35
  end
32
36