collavre 0.15.0 → 0.20.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/code_highlight.css +13 -0
  3. data/app/assets/stylesheets/collavre/comments_popup.css +65 -5
  4. data/app/assets/stylesheets/collavre/creatives.css +5 -2
  5. data/app/controllers/collavre/admin/settings_controller.rb +9 -0
  6. data/app/controllers/collavre/attachments_controller.rb +1 -1
  7. data/app/controllers/collavre/comment_read_pointers_controller.rb +2 -2
  8. data/app/controllers/collavre/comments/snapshots_controller.rb +2 -7
  9. data/app/controllers/collavre/comments_controller.rb +23 -25
  10. data/app/controllers/collavre/creatives_controller.rb +22 -11
  11. data/app/controllers/collavre/emails_controller.rb +2 -0
  12. data/app/controllers/collavre/google_auth_controller.rb +1 -1
  13. data/app/controllers/collavre/inbox_items_controller.rb +1 -1
  14. data/app/controllers/collavre/invites_controller.rb +3 -0
  15. data/app/controllers/collavre/tasks_controller.rb +1 -1
  16. data/app/controllers/collavre/topics_controller.rb +27 -85
  17. data/app/controllers/concerns/collavre/comments/comment_scoping.rb +3 -0
  18. data/app/controllers/concerns/collavre/creative_permission_guard.rb +32 -0
  19. data/app/controllers/concerns/collavre/integration_setup.rb +17 -0
  20. data/app/helpers/collavre/application_helper.rb +30 -2
  21. data/app/javascript/components/InlineLexicalEditor.jsx +7 -3
  22. data/app/javascript/components/creative_tree_row.js +21 -1
  23. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +34 -0
  24. data/app/javascript/controllers/comment_controller.js +19 -1
  25. data/app/javascript/controllers/comment_version_controller.js +2 -1
  26. data/app/javascript/controllers/comments/form_controller.js +7 -4
  27. data/app/javascript/controllers/comments/list_controller.js +43 -4
  28. data/app/javascript/controllers/comments/popup_controller.js +45 -12
  29. data/app/javascript/controllers/comments/presence_controller.js +8 -0
  30. data/app/javascript/controllers/comments/topics_controller.js +50 -31
  31. data/app/javascript/creatives/tree_renderer.js +1 -0
  32. data/app/javascript/lib/__tests__/chat_history.test.js +31 -0
  33. data/app/javascript/lib/chat_history.js +12 -2
  34. data/app/javascript/lib/utils/markdown.js +32 -1
  35. data/app/javascript/modules/command_args_form.js +8 -0
  36. data/app/javascript/modules/creative_row_editor.js +12 -17
  37. data/app/javascript/modules/integration_wizard.js +162 -0
  38. data/app/jobs/collavre/compress_job.rb +1 -0
  39. data/app/jobs/collavre/creative_broadcast_job.rb +4 -1
  40. data/app/jobs/collavre/merge_comments_job.rb +1 -0
  41. data/app/jobs/collavre/trigger_loop_check_job.rb +1 -0
  42. data/app/jobs/collavre/trigger_loop_verify_job.rb +1 -0
  43. data/app/models/collavre/calendar_event.rb +0 -4
  44. data/app/models/collavre/comment/broadcastable.rb +1 -1
  45. data/app/models/collavre/comment.rb +17 -2
  46. data/app/models/collavre/comment_snapshot.rb +0 -1
  47. data/app/models/collavre/creative/describable.rb +10 -1
  48. data/app/models/collavre/creative/realtime_broadcastable.rb +17 -5
  49. data/app/models/collavre/creative.rb +43 -1
  50. data/app/models/collavre/current.rb +1 -1
  51. data/app/models/collavre/inbox_item.rb +0 -4
  52. data/app/models/collavre/system_setting.rb +10 -1
  53. data/app/models/collavre/task.rb +17 -8
  54. data/app/models/collavre/user.rb +11 -1
  55. data/app/models/concerns/collavre/ai_agent_resolvable.rb +0 -8
  56. data/app/services/collavre/ai_agent/message_builder.rb +32 -15
  57. data/app/services/collavre/ai_agent/response_finalizer.rb +2 -1
  58. data/app/services/collavre/ai_agent/session_context_resolver.rb +50 -0
  59. data/app/services/collavre/ai_agent_service.rb +14 -2
  60. data/app/services/collavre/ai_client.rb +13 -0
  61. data/app/services/collavre/command_menu_service.rb +23 -3
  62. data/app/services/collavre/comments/command_processor.rb +1 -1
  63. data/app/services/collavre/comments/mcp_command.rb +27 -8
  64. data/app/services/collavre/comments/mcp_command_builder.rb +4 -3
  65. data/app/services/collavre/creatives/tree_builder.rb +3 -0
  66. data/app/services/collavre/google_calendar_service.rb +32 -6
  67. data/app/services/collavre/markdown_converter.rb +15 -20
  68. data/app/services/collavre/mcp_service.rb +4 -4
  69. data/app/services/collavre/orchestration/agent_orchestrator.rb +2 -2
  70. data/app/services/collavre/tools/creative_batch_service.rb +5 -1
  71. data/app/services/collavre/tools/cron_create_service.rb +9 -6
  72. data/app/services/collavre/topic_branch_service.rb +2 -2
  73. data/app/views/collavre/admin/settings/_system_tab.html.erb +11 -0
  74. data/app/views/collavre/comments/_comment.html.erb +7 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  76. data/config/locales/admin.en.yml +3 -0
  77. data/config/locales/admin.ko.yml +3 -0
  78. data/config/locales/comments.en.yml +6 -1
  79. data/config/locales/comments.ko.yml +6 -1
  80. data/db/migrate/20251126040752_add_description_to_creatives.rb +1 -1
  81. data/db/migrate/20260415000000_create_main_topics_for_existing_creatives.rb +69 -0
  82. data/lib/collavre/version.rb +1 -1
  83. metadata +21 -4
  84. data/app/jobs/collavre/permission_cache_cleanup_job.rb +0 -36
  85. data/app/models/collavre/variation.rb +0 -5
  86. data/app/services/collavre/creatives/path_exporter.rb +0 -131
@@ -33,6 +33,8 @@ export default class extends Controller {
33
33
  this.openFromUrlTimeout = null
34
34
  this.handleCreativeClick = this.handleCreativeClick.bind(this)
35
35
  this.handleCreativeDestroyed = this.handleCreativeDestroyed.bind(this)
36
+ this.handleEditingStart = this.handleEditingStart.bind(this)
37
+ this.handleEditingStop = this.handleEditingStop.bind(this)
36
38
  this.handleTouchStart = this.handleTouchStart.bind(this)
37
39
  this.handleTouchEnd = this.handleTouchEnd.bind(this)
38
40
  this.handleResizeMove = this.handleResizeMove.bind(this)
@@ -54,6 +56,8 @@ export default class extends Controller {
54
56
 
55
57
  document.addEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
56
58
  document.addEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
59
+ document.addEventListener('creative-editing:start', this.handleEditingStart)
60
+ document.addEventListener('creative-editing:stop', this.handleEditingStop)
57
61
  this.element.addEventListener('wheel', this.handlePopupWheel, { passive: false })
58
62
  window.addEventListener('online', this.handleOnline)
59
63
  window.addEventListener('focus', this.handleWindowFocus)
@@ -121,6 +125,8 @@ export default class extends Controller {
121
125
  this.clearPendingOpenFromUrl()
122
126
  document.removeEventListener(CREATIVE_CLICK_EVENT, this.handleCreativeClick)
123
127
  document.removeEventListener(CREATIVE_DESTROYED_EVENT, this.handleCreativeDestroyed)
128
+ document.removeEventListener('creative-editing:start', this.handleEditingStart)
129
+ document.removeEventListener('creative-editing:stop', this.handleEditingStop)
124
130
  this.element.removeEventListener('wheel', this.handlePopupWheel)
125
131
  window.removeEventListener('online', this.handleOnline)
126
132
  window.removeEventListener('focus', this.handleWindowFocus)
@@ -202,6 +208,16 @@ export default class extends Controller {
202
208
  }
203
209
  }
204
210
 
211
+ handleEditingStart() {
212
+ if (this.element.style.display === 'flex' && !this.isFullscreen()) {
213
+ this.element.classList.add('editor-behind')
214
+ }
215
+ }
216
+
217
+ handleEditingStop() {
218
+ this.element.classList.remove('editor-behind')
219
+ }
220
+
205
221
  async open(button, { creativeId, highlightId } = {}) {
206
222
  this.currentButton = button
207
223
  const resolvedCreativeId = creativeId || button?.dataset.creativeId
@@ -378,7 +394,7 @@ export default class extends Controller {
378
394
  this._releaseWakeLock()
379
395
 
380
396
  this.element.style.display = 'none'
381
- this.element.classList.remove('open')
397
+ this.element.classList.remove('open', 'editor-behind')
382
398
  this.element.style.width = ''
383
399
  this.element.style.height = ''
384
400
  this.element.style.left = ''
@@ -623,6 +639,16 @@ export default class extends Controller {
623
639
  // Enter fullscreen immediately without animation (for auto-fullscreen on page load)
624
640
  _enterFullscreenImmediate() {
625
641
  const el = this.element
642
+
643
+ // Save current inline styles so exit-fullscreen can restore them
644
+ this._savedStyles = {
645
+ top: el.style.top,
646
+ right: el.style.right,
647
+ left: el.style.left,
648
+ width: el.style.width,
649
+ height: el.style.height,
650
+ }
651
+
626
652
  el.style.transition = 'none'
627
653
  el.dataset.fullscreen = 'true'
628
654
  document.body.classList.add('chat-fullscreen')
@@ -695,8 +721,10 @@ export default class extends Controller {
695
721
  }
696
722
 
697
723
  // Clean up inline styles after transition ends
698
- const cleanup = () => {
699
- el.removeEventListener('transitionend', cleanup)
724
+ this._enterCleanupFn = () => {
725
+ el.removeEventListener('transitionend', this._enterCleanupFn)
726
+ this._enterCleanupTimer = null
727
+ this._enterCleanupFn = null
700
728
  el.style.top = ''
701
729
  el.style.left = ''
702
730
  el.style.right = ''
@@ -705,11 +733,22 @@ export default class extends Controller {
705
733
  el.style.height = ''
706
734
  el.style.position = ''
707
735
  }
708
- el.addEventListener('transitionend', cleanup, { once: true })
736
+ el.addEventListener('transitionend', this._enterCleanupFn, { once: true })
709
737
  // Fallback if transitionend doesn't fire
710
- setTimeout(cleanup, 300)
738
+ this._enterCleanupTimer = setTimeout(this._enterCleanupFn, 300)
711
739
 
712
740
  } else {
741
+ // Cancel any pending enter-fullscreen cleanup to prevent it from
742
+ // wiping inline styles mid-exit animation (race condition fix)
743
+ if (this._enterCleanupTimer) {
744
+ clearTimeout(this._enterCleanupTimer)
745
+ this._enterCleanupTimer = null
746
+ }
747
+ if (this._enterCleanupFn) {
748
+ el.removeEventListener('transitionend', this._enterCleanupFn)
749
+ this._enterCleanupFn = null
750
+ }
751
+
713
752
  const savedStyles = this._savedStyles
714
753
  this._savedStyles = null
715
754
  const creativeId = el.dataset.creativeId
@@ -1062,21 +1101,15 @@ export default class extends Controller {
1062
1101
  if (hasOverflowY || hasOverflowX) {
1063
1102
  const style = getComputedStyle(el)
1064
1103
 
1065
- // Check dominant axis first for better matching
1066
1104
  if (dominantAxis === 'x' && hasOverflowX) {
1067
1105
  const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
1068
1106
  if (scrollableX) return { element: el, axis: 'x' }
1069
1107
  }
1070
1108
 
1071
- if (hasOverflowY) {
1109
+ if (dominantAxis === 'y' && hasOverflowY) {
1072
1110
  const scrollableY = style.overflowY === 'auto' || style.overflowY === 'scroll'
1073
1111
  if (scrollableY) return { element: el, axis: 'y' }
1074
1112
  }
1075
-
1076
- if (dominantAxis !== 'x' && hasOverflowX) {
1077
- const scrollableX = style.overflowX === 'auto' || style.overflowX === 'scroll'
1078
- if (scrollableX) return { element: el, axis: 'x' }
1079
- }
1080
1113
  }
1081
1114
 
1082
1115
  el = el.parentElement
@@ -64,6 +64,7 @@ export default class extends Controller {
64
64
  this.currentPresentIds = []
65
65
  this.typingUsers = {}
66
66
  this.activeAgentTasks = {}
67
+ this.syncGlobalAgentTasks()
67
68
  this.clearTypingTimers()
68
69
  this.clearManualTypingMessage()
69
70
  this.renderParticipants([])
@@ -217,6 +218,7 @@ export default class extends Controller {
217
218
  delete this.typingUsers[id]
218
219
  delete this.agentStatusTimers[id]
219
220
  delete this.activeAgentTasks?.[id]
221
+ this.syncGlobalAgentTasks()
220
222
  this.renderTypingIndicator()
221
223
  }, AGENT_STATUS_TIMEOUT)
222
224
  } else {
@@ -227,6 +229,7 @@ export default class extends Controller {
227
229
  delete this.agentStatusTimers[id]
228
230
  }
229
231
  }
232
+ this.syncGlobalAgentTasks()
230
233
  this.renderTypingIndicator()
231
234
  }
232
235
  }
@@ -380,6 +383,10 @@ export default class extends Controller {
380
383
  this.typingIndicatorTarget.appendChild(text)
381
384
  }
382
385
 
386
+ syncGlobalAgentTasks() {
387
+ // no-op: stop button is now server-rendered per comment via task_id
388
+ }
389
+
383
390
  cancelAllAgentTasks() {
384
391
  const ids = Object.keys(this.activeAgentTasks || {})
385
392
  if (ids.length === 0) return false
@@ -403,6 +410,7 @@ export default class extends Controller {
403
410
  clearTimeout(this.agentStatusTimers[agentId])
404
411
  delete this.agentStatusTimers[agentId]
405
412
  }
413
+ this.syncGlobalAgentTasks()
406
414
  this.renderTypingIndicator()
407
415
  }
408
416
  })
@@ -14,6 +14,7 @@ export default class extends Controller {
14
14
  this.canCreateTopic = false
15
15
  this.subscribedCreativeId = null
16
16
  this.topicsSubscription = null
17
+ this._loadTopicsVersion = 0
17
18
  // Initial load if creativeId is available (e.g. from dataset if set server-side)
18
19
  if (this.creativeId) {
19
20
  this.loadTopics()
@@ -61,8 +62,16 @@ export default class extends Controller {
61
62
  async loadTopics() {
62
63
  if (!this.creativeId) return
63
64
 
65
+ const version = ++this._loadTopicsVersion
66
+ // Clear stale topics from previous creative to prevent name-based
67
+ // dedupe in handleTopicMessage from blocking valid broadcasts
68
+ this.topics = []
69
+
64
70
  try {
65
71
  const response = await fetch(`/creatives/${this.creativeId}/topics`)
72
+ // Discard stale response if a newer loadTopics() call was made
73
+ if (version !== this._loadTopicsVersion) return
74
+
66
75
  if (response.status === 404) {
67
76
  throw new Error(`Creative ${this.creativeId} not found`)
68
77
  }
@@ -78,6 +87,7 @@ export default class extends Controller {
78
87
  this.serverLastTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
79
88
  this.isInbox = !!data.is_inbox
80
89
  this.systemTopicId = data.system_topic_id ? String(data.system_topic_id) : null
90
+ this.mainTopicId = data.main_topic_id ? String(data.main_topic_id) : null
81
91
 
82
92
  // Migrate localStorage to server if server has no value
83
93
  this.migrateLocalStorage()
@@ -102,10 +112,7 @@ export default class extends Controller {
102
112
  }
103
113
  }
104
114
 
105
- // Always dispatch change event to ensure form controller gets the correct topic.
106
- // Without this, switching to a creative with no stored topic leaves the form
107
- // controller with a stale topic_id from the previous creative.
108
- this.selectTopic("")
115
+ this.selectTopic(this.mainTopicId || "")
109
116
  }
110
117
 
111
118
  renderTopics(topics, canManage = false, canCreateTopic = canManage) {
@@ -117,30 +124,39 @@ export default class extends Controller {
117
124
  ? 'dragover->comments--topics#handleTopicReorderDragOver dragleave->comments--topics#handleTopicReorderDragLeave drop->comments--topics#handleTopicReorderDrop'
118
125
  : ''
119
126
 
120
- let html = `<span class="topic-tag topic-drop-target ${this.currentTopicId ? '' : 'active'}"
121
- data-action="click->comments--topics#select ${dropActions}"
122
- data-id="">#Main</span>`
127
+ const allMessagesLabel = this.element.dataset.topicMainText || 'All Messages'
128
+
129
+ const mainTopic = this.mainTopicId ? topics.find(t => String(t.id) === String(this.mainTopicId)) : null
130
+ const otherTopics = topics.filter(t => !mainTopic || String(t.id) !== String(this.mainTopicId))
131
+
132
+ let html = ''
123
133
 
124
- topics.forEach(topic => {
125
- // Ensure comparison handles string/number difference
134
+ const renderTopic = (topic) => {
126
135
  const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
127
136
  const draggable = canManage ? 'draggable="true"' : ''
128
137
  const agentAvatar = topic.primary_agent
129
138
  ? this.renderAgentAvatar(topic.primary_agent)
130
139
  : ''
131
140
  const branchIcon = topic.source_topic_id ? '<span class="topic-branch-icon" title="Branched">↗</span>' : ''
132
- html += `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
141
+ const isMainTopic = this.mainTopicId && String(topic.id) === String(this.mainTopicId)
142
+ let s = `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
133
143
  data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
134
144
  data-id="${topic.id}"${topic.source_topic_id ? ` data-source-topic-id="${topic.source_topic_id}"` : ''}>
135
145
  ${agentAvatar}${branchIcon}#${topic.name}`
136
-
137
- if (canManage) {
138
- html += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
139
- html += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">&times;</button>`
146
+ if (canManage && !isMainTopic) {
147
+ s += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
148
+ s += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">&times;</button>`
140
149
  }
150
+ s += `</span>`
151
+ return s
152
+ }
141
153
 
142
- html += `</span>`
143
- })
154
+ if (mainTopic) html += renderTopic(mainTopic)
155
+ otherTopics.forEach(topic => { html += renderTopic(topic) })
156
+
157
+ html += `<span class="topic-tag topic-drop-target topic-all-messages ${this.currentTopicId ? '' : 'active'}"
158
+ data-action="click->comments--topics#select ${dropActions}"
159
+ data-id="">📋 ${allMessagesLabel}</span>`
144
160
 
145
161
  // Archived topics section
146
162
  if (this.archivedTopics && this.archivedTopics.length > 0) {
@@ -191,7 +207,7 @@ export default class extends Controller {
191
207
  const agentJson = event.dataTransfer.getData('application/x-agent-drop')
192
208
  if (agentJson) {
193
209
  const agent = JSON.parse(agentJson)
194
- const targetTopicId = event.currentTarget.dataset.id
210
+ const targetTopicId = event.currentTarget.dataset.id || this.mainTopicId
195
211
  if (targetTopicId) {
196
212
  await this.setTopicPrimaryAgent(targetTopicId, agent)
197
213
  }
@@ -205,7 +221,7 @@ export default class extends Controller {
205
221
  const commentIds = JSON.parse(commentIdsJson)
206
222
  if (!commentIds || commentIds.length === 0) return
207
223
 
208
- const targetTopicId = event.currentTarget.dataset.id // Empty string for Main
224
+ const targetTopicId = event.currentTarget.dataset.id || this.mainTopicId
209
225
 
210
226
  // Dispatch event for list_controller to handle the move
211
227
  this.dispatch('move-to-topic', {
@@ -363,7 +379,7 @@ export default class extends Controller {
363
379
  if (response.ok) {
364
380
  if (String(this.currentTopicId) === String(topicId)) {
365
381
  this.currentTopicId = "" // Switch to Main
366
- this.dispatch("change", { detail: { topicId: "" } })
382
+ this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
367
383
  }
368
384
  this.loadTopics()
369
385
  } else {
@@ -390,7 +406,7 @@ export default class extends Controller {
390
406
  if (response.ok) {
391
407
  if (String(this.currentTopicId) === String(topicId)) {
392
408
  this.currentTopicId = ""
393
- this.dispatch("change", { detail: { topicId: "" } })
409
+ this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
394
410
  }
395
411
  this.loadTopics()
396
412
  } else {
@@ -501,7 +517,7 @@ export default class extends Controller {
501
517
  this.clearNewMessageBadge(id)
502
518
  }
503
519
  // Dispatch event
504
- this.dispatch("change", { detail: { topicId: id, isInbox: this.isInbox, systemTopicId: this.systemTopicId } })
520
+ this.dispatch("change", { detail: { topicId: id, isInbox: this.isInbox, systemTopicId: this.systemTopicId, mainTopicId: this.mainTopicId } })
505
521
  }
506
522
 
507
523
  showEditInput(topicEl, topicId) {
@@ -570,10 +586,9 @@ export default class extends Controller {
570
586
 
571
587
  if (response.ok) {
572
588
  const updatedTopic = await response.json()
573
- // Update local topics array
574
589
  const index = this.topics.findIndex(t => String(t.id) === String(topicId))
575
590
  if (index !== -1) {
576
- this.topics[index] = updatedTopic
591
+ this.topics[index] = { ...this.topics[index], ...updatedTopic }
577
592
  }
578
593
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
579
594
  this.restoreSelection()
@@ -619,7 +634,7 @@ export default class extends Controller {
619
634
  // If we were viewing the moved topic, switch to Main
620
635
  if (String(this.currentTopicId) === String(topicId)) {
621
636
  this.currentTopicId = ""
622
- this.dispatch("change", { detail: { topicId: "" } })
637
+ this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
623
638
  }
624
639
  this.loadTopics()
625
640
  }
@@ -667,7 +682,7 @@ export default class extends Controller {
667
682
  await this.flushSaveLastTopic(topic.id)
668
683
  await this.loadTopics()
669
684
  // Dispatch change event manually since we skipped the click handler
670
- this.dispatch("change", { detail: { topicId: topic.id } })
685
+ this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
671
686
  } else {
672
687
  alert("Failed to create topic")
673
688
  }
@@ -796,8 +811,12 @@ export default class extends Controller {
796
811
  if (!data.topic) return
797
812
 
798
813
  const topics = this.topics || []
799
- const exists = topics.some((topic) => String(topic.id) === String(data.topic.id))
800
- if (exists) return
814
+ const existsById = topics.some((topic) => String(topic.id) === String(data.topic.id))
815
+ if (existsById) return
816
+ // Prevent duplicate topic names (e.g. two "Main" topics from race between
817
+ // HTTP loadTopics and WebSocket broadcast)
818
+ const existsByName = data.topic.name && topics.some((topic) => topic.name === data.topic.name)
819
+ if (existsByName) return
801
820
 
802
821
  this.topics = [...topics, data.topic]
803
822
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
@@ -837,7 +856,7 @@ export default class extends Controller {
837
856
  const index = topics.findIndex(t => String(t.id) === String(updatedTopic.id))
838
857
  if (index === -1) return
839
858
 
840
- this.topics[index] = updatedTopic
859
+ this.topics[index] = { ...this.topics[index], ...updatedTopic }
841
860
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
842
861
  this.restoreSelection()
843
862
  }
@@ -852,7 +871,7 @@ export default class extends Controller {
852
871
  this.topics = nextTopics
853
872
  if (String(this.currentTopicId) === String(topicId)) {
854
873
  this.currentTopicId = ""
855
- this.dispatch("change", { detail: { topicId: "" } })
874
+ this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
856
875
  }
857
876
 
858
877
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
@@ -901,7 +920,7 @@ export default class extends Controller {
901
920
  this.currentTopicId = result.topic.id
902
921
  await this.flushSaveLastTopic(result.topic.id)
903
922
  await this.loadTopics()
904
- this.dispatch("change", { detail: { topicId: result.topic.id } })
923
+ this.dispatch("change", { detail: { topicId: result.topic.id, mainTopicId: this.mainTopicId } })
905
924
 
906
925
  // Clear selection in list controller
907
926
  const listController = this.application.getControllerForElementAndIdentifier(
@@ -956,7 +975,7 @@ export default class extends Controller {
956
975
  this.currentTopicId = topic.id
957
976
  await this.flushSaveLastTopic(topic.id)
958
977
  await this.loadTopics()
959
- this.dispatch("change", { detail: { topicId: topic.id } })
978
+ this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
960
979
  } else {
961
980
  console.error('Failed to create topic with agent')
962
981
  }
@@ -85,6 +85,7 @@ function applyRowProperties(row, node) {
85
85
  updateBooleanAttr('expanded', 'expanded', node.expanded)
86
86
  updateBooleanAttr('isRoot', 'is-root', node.is_root)
87
87
  updateBooleanAttr('archived', 'archived', node.archived)
88
+ updateBooleanAttr('githubSource', 'github-source', node.github_source)
88
89
 
89
90
  if (node.link_url) {
90
91
  if (row.linkUrl !== node.link_url) {
@@ -36,6 +36,37 @@ describe('ChatNavigationHistory', () => {
36
36
  expect(history.current().snippet).toBe('A updated')
37
37
  })
38
38
 
39
+ test('revisit reorders entry to reflect visit order', () => {
40
+ history.push({ creativeId: '1', snippet: 'A' })
41
+ history.push({ creativeId: '2', snippet: 'B' })
42
+ history.push({ creativeId: '3', snippet: 'C' })
43
+ history.push({ creativeId: '4', snippet: 'D' })
44
+ // entries: [A, B, C, D], cursor at D (index 3)
45
+
46
+ history.push({ creativeId: '2', snippet: 'B' })
47
+ // B should move after D: [A, C, D, B], cursor at B (index 3)
48
+ expect(history.entries.map(e => e.creativeId)).toEqual(['1', '3', '4', '2'])
49
+ expect(history.current().creativeId).toBe('2')
50
+
51
+ // prev should go back to D (where we came from)
52
+ expect(history.prev().creativeId).toBe('4')
53
+ expect(history.prev().creativeId).toBe('3')
54
+ expect(history.prev().creativeId).toBe('1')
55
+ // wraps around
56
+ expect(history.prev().creativeId).toBe('2')
57
+ })
58
+
59
+ test('revisit current entry does not reorder', () => {
60
+ history.push({ creativeId: '1', snippet: 'A' })
61
+ history.push({ creativeId: '2', snippet: 'B' })
62
+ history.push({ creativeId: '3', snippet: 'C' })
63
+
64
+ history.push({ creativeId: '3', snippet: 'C updated' })
65
+ expect(history.entries.map(e => e.creativeId)).toEqual(['1', '2', '3'])
66
+ expect(history.current().creativeId).toBe('3')
67
+ expect(history.current().snippet).toBe('C updated')
68
+ })
69
+
39
70
  test('prev cycles backward', () => {
40
71
  history.push({ creativeId: '1', snippet: 'A' })
41
72
  history.push({ creativeId: '2', snippet: 'B' })
@@ -33,10 +33,20 @@ class ChatNavigationHistory {
33
33
  )
34
34
 
35
35
  if (existingIdx !== -1) {
36
- // Already in list — update metadata and move cursor there
37
36
  this.entries[existingIdx].snippet = entry.snippet || this.entries[existingIdx].snippet
38
37
  this.entries[existingIdx].canComment = !!entry.canComment
39
- this.currentIndex = existingIdx
38
+
39
+ if (existingIdx === this.currentIndex) {
40
+ this._save()
41
+ return
42
+ }
43
+
44
+ // Move to after current position to reflect visit order (LRU)
45
+ const [moved] = this.entries.splice(existingIdx, 1)
46
+ if (existingIdx < this.currentIndex) this.currentIndex--
47
+ const insertAt = this.currentIndex + 1
48
+ this.entries.splice(insertAt, 0, moved)
49
+ this.currentIndex = insertAt
40
50
  this._save()
41
51
  return
42
52
  }
@@ -68,11 +68,15 @@ function sanitizeLang(lang) {
68
68
  return lang.replace(/[^a-zA-Z0-9_-]/g, '')
69
69
  }
70
70
 
71
- // Custom renderer for code blocks with syntax highlighting
71
+ // Custom renderer for code blocks with syntax highlighting + mermaid
72
72
  marked.use({
73
73
  renderer: {
74
74
  code({ text, lang }) {
75
75
  const safeLang = sanitizeLang(lang)
76
+ if (safeLang === 'mermaid') {
77
+ const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
78
+ return `<div class="mermaid-chart">${escaped}</div>`
79
+ }
76
80
  const highlighted = highlightCode(text, safeLang)
77
81
  const langClass = safeLang ? ` language-${safeLang}` : ''
78
82
  return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>`
@@ -99,6 +103,14 @@ DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
99
103
  data.forceKeepAttr = true
100
104
  }
101
105
  }
106
+ // Allow mermaid-chart class on div elements
107
+ if (node.tagName === 'DIV' && data.attrName === 'class') {
108
+ const classes = data.attrValue.split(/\s+/)
109
+ if (classes.includes('mermaid-chart')) {
110
+ data.attrValue = 'mermaid-chart'
111
+ data.forceKeepAttr = true
112
+ }
113
+ }
102
114
  })
103
115
 
104
116
  const PURIFY_CONFIG = {
@@ -125,6 +137,24 @@ export function renderCommentMarkdown(text) {
125
137
  return sanitize(html.trim())
126
138
  }
127
139
 
140
+ // Lazy-load mermaid and render diagrams in a container
141
+ let mermaidReady = false
142
+
143
+ export async function renderMermaidDiagrams(container) {
144
+ const charts = container.querySelectorAll('.mermaid-chart:not([data-processed])')
145
+ if (charts.length === 0) return
146
+ const { default: mermaid } = await import('mermaid')
147
+ if (!mermaidReady) {
148
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
149
+ mermaidReady = true
150
+ }
151
+ try {
152
+ await mermaid.run({ nodes: Array.from(charts) })
153
+ } catch (e) {
154
+ console.warn('Mermaid rendering failed:', e)
155
+ }
156
+ }
157
+
128
158
  export function renderMarkdownInContainer(container) {
129
159
  container.querySelectorAll('.comment-content').forEach((element) => {
130
160
  if (element.dataset.rendered === 'true') return
@@ -132,4 +162,5 @@ export function renderMarkdownInContainer(container) {
132
162
  element.dataset.rendered = 'true'
133
163
  addTableDownloadButtons(element)
134
164
  })
165
+ renderMermaidDiagrams(container)
135
166
  }
@@ -241,6 +241,14 @@ export default class CommandArgsForm {
241
241
  input.dataset.paramRequired = param.required ? 'true' : 'false'
242
242
  if (param.description) input.placeholder = param.description
243
243
 
244
+ // Pre-fill with default value from creative tool defaults
245
+ if (param.default_value != null) {
246
+ input.value = String(param.default_value)
247
+ if (input.tagName === 'TEXTAREA') {
248
+ requestAnimationFrame(() => this._autoResize(input))
249
+ }
250
+ }
251
+
244
252
  // Enter submits; Shift+Enter inserts newline (textarea only)
245
253
  input.addEventListener('keydown', (e) => {
246
254
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -1095,11 +1095,9 @@ export function initializeCreativeRowEditor() {
1095
1095
  // Notify sync controller that editing stopped
1096
1096
  stopEditingPing();
1097
1097
  const editCreativeId = form.dataset.creativeId;
1098
- if (editCreativeId) {
1099
- document.dispatchEvent(new CustomEvent('creative-editing:stop', {
1100
- detail: { creativeId: parseInt(editCreativeId, 10) }
1101
- }));
1102
- }
1098
+ document.dispatchEvent(new CustomEvent('creative-editing:stop', {
1099
+ detail: { creativeId: editCreativeId ? parseInt(editCreativeId, 10) : null }
1100
+ }));
1103
1101
 
1104
1102
  currentTree = null;
1105
1103
  currentRowElement = null;
@@ -1749,6 +1747,9 @@ export function initializeCreativeRowEditor() {
1749
1747
  pendingSave = false;
1750
1748
  lexicalEditor.focus();
1751
1749
  updateActionButtonStates();
1750
+ document.dispatchEvent(new CustomEvent('creative-editing:start', {
1751
+ detail: { creativeId: null }
1752
+ }));
1752
1753
  if (parentSuggestions) {
1753
1754
  parentSuggestions.style.display = 'none';
1754
1755
  parentSuggestions.innerHTML = '';
@@ -1786,17 +1787,13 @@ export function initializeCreativeRowEditor() {
1786
1787
  scheduleSave();
1787
1788
  }
1788
1789
 
1789
- // Intercepts bare Enter via capture-phase keydown on Lexical's root element.
1790
- // Returning true triggers preventDefault + stopImmediatePropagation,
1791
- // preventing Lexical from inserting a paragraph node.
1792
- // PC: Enter → addNew (complete & move to next)
1793
- // Mobile: Enter → newline (let Lexical handle it)
1790
+ // Intercepts Shift+Enter via capture-phase keydown on Lexical's root element.
1791
+ // Returning true triggers preventDefault + stopImmediatePropagation.
1792
+ // Shift+Enter addNew (save & add next)
1793
+ // Bare Enter → newline (handled by Lexical)
1794
1794
  function handleEditorEnterKey(event, editorInstance) {
1795
- if (window.innerWidth > 600) {
1796
- addNew();
1797
- return true; // handled — suppress Lexical's newline
1798
- }
1799
- return false; // mobile: let Lexical insert newline
1795
+ addNew();
1796
+ return true; // handled — suppress default behavior
1800
1797
  }
1801
1798
 
1802
1799
  function handleEditorKeyDown(event, editorInstance) {
@@ -1811,8 +1808,6 @@ export function initializeCreativeRowEditor() {
1811
1808
  addChild();
1812
1809
  return;
1813
1810
  }
1814
- // Note: bare Enter is handled by handleEditorEnterKey via capture-phase keydown
1815
- // on Lexical's root element to prevent newline insertion before addNew().
1816
1811
  if ((event.ctrlKey || event.metaKey) && event.shiftKey && (event.key === '.' || event.key === '>')) {
1817
1812
  event.preventDefault();
1818
1813
  levelDown();