collavre 0.20.3 → 0.22.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/collavre/actiontext.css +92 -2
  3. data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
  4. data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/assets/stylesheets/collavre/popup.css +148 -0
  7. data/app/channels/collavre/agent_channel.rb +205 -0
  8. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  9. data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
  10. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  11. data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
  12. data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
  13. data/app/controllers/collavre/application_controller.rb +27 -0
  14. data/app/controllers/collavre/attachments_controller.rb +30 -2
  15. data/app/controllers/collavre/channels_controller.rb +23 -0
  16. data/app/controllers/collavre/comments_controller.rb +1 -1
  17. data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
  18. data/app/controllers/collavre/creatives_controller.rb +141 -7
  19. data/app/controllers/collavre/landing_controller.rb +8 -0
  20. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  21. data/app/controllers/collavre/tasks_controller.rb +12 -4
  22. data/app/controllers/collavre/topics_controller.rb +36 -30
  23. data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
  24. data/app/helpers/collavre/comments_helper.rb +7 -0
  25. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  26. data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
  27. data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
  28. data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
  29. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
  30. data/app/javascript/controllers/comment_controller.js +15 -1
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
  32. data/app/javascript/controllers/comments/form_controller.js +4 -0
  33. data/app/javascript/controllers/comments/list_controller.js +27 -9
  34. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  35. data/app/javascript/controllers/comments/presence_controller.js +137 -4
  36. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  37. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  38. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  39. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  40. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  41. data/app/javascript/controllers/index.js +4 -1
  42. data/app/javascript/controllers/landing_video_controller.js +53 -0
  43. data/app/javascript/controllers/link_creative_controller.js +451 -29
  44. data/app/javascript/creatives/tree_renderer.js +6 -0
  45. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  46. data/app/javascript/lib/api/creatives.js +13 -0
  47. data/app/javascript/lib/api/queue_manager.js +17 -5
  48. data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
  49. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
  50. data/app/javascript/lib/lexical/color_import.js +186 -0
  51. data/app/javascript/lib/lexical/minimize_html.js +182 -0
  52. data/app/javascript/lib/lexical/video_node.jsx +96 -0
  53. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  54. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  55. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  56. data/app/javascript/modules/command_args_form.js +22 -4
  57. data/app/javascript/modules/command_menu.js +27 -0
  58. data/app/javascript/modules/creative_row_editor.js +227 -17
  59. data/app/javascript/modules/html_content_empty.js +12 -0
  60. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  61. data/app/jobs/collavre/ai_agent_job.rb +89 -3
  62. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
  63. data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
  64. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  65. data/app/mailers/collavre/application_mailer.rb +1 -1
  66. data/app/models/collavre/agent_subscription.rb +52 -0
  67. data/app/models/collavre/channel/injected_message.rb +5 -0
  68. data/app/models/collavre/channel.rb +87 -0
  69. data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
  70. data/app/models/collavre/comment.rb +70 -5
  71. data/app/models/collavre/creative/describable.rb +202 -3
  72. data/app/models/collavre/creative.rb +2 -0
  73. data/app/models/collavre/creative_share.rb +1 -0
  74. data/app/models/collavre/integration_setting.rb +35 -0
  75. data/app/models/collavre/preview_channel.rb +93 -0
  76. data/app/models/collavre/system_setting.rb +13 -2
  77. data/app/models/collavre/task.rb +34 -5
  78. data/app/models/collavre/topic.rb +8 -25
  79. data/app/models/collavre/user.rb +4 -0
  80. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  81. data/app/services/collavre/agent_session_abort.rb +28 -0
  82. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
  83. data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
  84. data/app/services/collavre/ai_agent_service.rb +68 -49
  85. data/app/services/collavre/ai_client.rb +3 -3
  86. data/app/services/collavre/attachment_backfill.rb +26 -0
  87. data/app/services/collavre/channel_attacher.rb +58 -0
  88. data/app/services/collavre/comments/mcp_command.rb +31 -1
  89. data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
  90. data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
  91. data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
  92. data/app/services/collavre/creatives/index_query.rb +110 -8
  93. data/app/services/collavre/creatives/permission_filter.rb +50 -0
  94. data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
  95. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  96. data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
  97. data/app/services/collavre/google_calendar_service.rb +4 -2
  98. data/app/services/collavre/markdown_converter.rb +130 -15
  99. data/app/services/collavre/markdown_importer.rb +7 -2
  100. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  101. data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
  102. data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
  103. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  104. data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
  105. data/app/services/collavre/tools/cron_list_service.rb +1 -14
  106. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  107. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  108. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  109. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  110. data/app/services/collavre/topic_branch_service.rb +34 -26
  111. data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
  112. data/app/views/admin/shared/_tabs.html.erb +1 -0
  113. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  114. data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
  115. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  116. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  117. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  118. data/app/views/collavre/comments/_comment.html.erb +16 -2
  119. data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
  120. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  121. data/app/views/collavre/creatives/index.html.erb +10 -2
  122. data/app/views/collavre/landing/show.html.erb +130 -0
  123. data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
  124. data/app/views/layouts/collavre/landing.html.erb +33 -0
  125. data/config/locales/admin.en.yml +4 -2
  126. data/config/locales/admin.ko.yml +4 -2
  127. data/config/locales/channels.en.yml +13 -0
  128. data/config/locales/channels.ko.yml +13 -0
  129. data/config/locales/claude_channel.en.yml +16 -0
  130. data/config/locales/claude_channel.ko.yml +16 -0
  131. data/config/locales/comments.en.yml +5 -0
  132. data/config/locales/comments.ko.yml +5 -0
  133. data/config/locales/creatives.en.yml +11 -0
  134. data/config/locales/creatives.ko.yml +10 -0
  135. data/config/locales/integrations.en.yml +55 -0
  136. data/config/locales/integrations.ko.yml +55 -0
  137. data/config/locales/landing.en.yml +51 -0
  138. data/config/locales/landing.ko.yml +51 -0
  139. data/config/routes.rb +30 -0
  140. data/db/migrate/20260526000000_create_channels.rb +42 -0
  141. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  142. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  143. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  144. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  145. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  146. data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
  147. data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
  148. data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
  149. data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
  150. data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
  151. data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
  152. data/db/seeds.rb +19 -0
  153. data/lib/collavre/aws_credentials.rb +75 -0
  154. data/lib/collavre/engine.rb +50 -0
  155. data/lib/collavre/integration_settings/key_definition.rb +35 -0
  156. data/lib/collavre/integration_settings/registry.rb +60 -0
  157. data/lib/collavre/integration_settings/resolver.rb +71 -0
  158. data/lib/collavre/integration_settings.rb +46 -0
  159. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  160. data/lib/collavre/version.rb +1 -1
  161. data/lib/collavre.rb +3 -0
  162. metadata +82 -2
  163. data/app/services/collavre/openclaw_abort_service.rb +0 -45
@@ -20,13 +20,16 @@ export default class CommandArgsForm {
20
20
  * @param {Element} opts.container - if set, the modal is scoped inside this element
21
21
  * (overlay covers only the container, content is blurred)
22
22
  * @param {Function} opts.creativeIdFn - returns current creative ID for mention search
23
+ * @param {Function} opts.contextValuesFn - returns { creative_id, topic_id } used to
24
+ * pre-fill params whose name matches
23
25
  */
24
- constructor({ onSubmit, onCancel, labels, container, creativeIdFn } = {}) {
26
+ constructor({ onSubmit, onCancel, labels, container, creativeIdFn, contextValuesFn } = {}) {
25
27
  this.onSubmit = onSubmit || (() => {})
26
28
  this.onCancel = onCancel || (() => {})
27
29
  this.labels = labels || { submit: 'OK', cancel: 'Cancel' }
28
30
  this.container = container || null
29
31
  this._creativeIdFn = creativeIdFn || (() => null)
32
+ this._contextValuesFn = contextValuesFn || (() => ({}))
30
33
  this.command = null
31
34
  this.overlay = null
32
35
  this.dialog = null
@@ -173,6 +176,18 @@ export default class CommandArgsForm {
173
176
  host.appendChild(this.dialog)
174
177
  }
175
178
 
179
+ _contextDefaultFor(paramName) {
180
+ if (paramName !== 'creative_id' && paramName !== 'topic_id') return null
181
+ let ctx
182
+ try {
183
+ ctx = this._contextValuesFn() || {}
184
+ } catch (_e) {
185
+ return null
186
+ }
187
+ const value = ctx[paramName]
188
+ return value == null || value === '' ? null : value
189
+ }
190
+
176
191
  _buildField(param, index) {
177
192
  const wrapper = document.createElement('div')
178
193
  wrapper.className = 'modal-dialog-field'
@@ -241,9 +256,12 @@ export default class CommandArgsForm {
241
256
  input.dataset.paramRequired = param.required ? 'true' : 'false'
242
257
  if (param.description) input.placeholder = param.description
243
258
 
244
- // Pre-fill with default value from creative tool defaults
245
- if (param.default_value != null) {
246
- input.value = String(param.default_value)
259
+ // Pre-fill priority: explicit default_value (from creative tool config) wins,
260
+ // otherwise auto-fill creative_id / topic_id from the current chat context.
261
+ const contextFill = this._contextDefaultFor(param.name)
262
+ const prefill = param.default_value != null ? param.default_value : contextFill
263
+ if (prefill != null && prefill !== '') {
264
+ input.value = String(prefill)
247
265
  if (input.tagName === 'TEXTAREA') {
248
266
  requestAnimationFrame(() => this._autoResize(input))
249
267
  }
@@ -19,6 +19,14 @@ if (!commandMenuInitialized) {
19
19
  const argsForm = new CommandArgsForm({
20
20
  container: popup,
21
21
  creativeIdFn: () => popup.dataset.creativeId,
22
+ contextValuesFn: () => ({
23
+ // Prefer the effective origin id (what comments/topics controllers
24
+ // operate on). For linked creatives, popup.dataset.creativeId is the
25
+ // wrapper row id, but the server resolves through effective_origin —
26
+ // MCP tools expect the same effective id.
27
+ creative_id: popup.dataset.effectiveCreativeId || popup.dataset.creativeId || null,
28
+ topic_id: currentTopicIdFromController(popup) || null
29
+ }),
22
30
  labels: {
23
31
  submit: menu.dataset.formSubmit || 'OK',
24
32
  cancel: menu.dataset.formCancel || 'Cancel'
@@ -79,6 +87,25 @@ if (!commandMenuInitialized) {
79
87
  }
80
88
  })
81
89
 
90
+ function currentTopicIdFromController(popupEl) {
91
+ // Prefer the form controller's locally-cached topic id — the same value
92
+ // used when submitting a comment. It is set from the
93
+ // `comments--topics:change` event, so it reflects the user's actual
94
+ // selection after restoreSelection rather than a stale URL deep-link.
95
+ const formCtrl = window.Stimulus?.getControllerForElementAndIdentifier(popupEl, 'comments--form')
96
+ if (formCtrl) {
97
+ const id = formCtrl.currentTopicId || formCtrl._mainTopicId
98
+ if (id) return String(id)
99
+ }
100
+ // Topics may not have loaded yet (form hasn't received any change event).
101
+ // Fall back to mainTopicId only — never topicsCtrl.currentTopicId, whose
102
+ // getter prioritizes window.location.search?topic_id= even when that
103
+ // topic does not belong to the current creative.
104
+ const topicsCtrl = window.Stimulus?.getControllerForElementAndIdentifier(popupEl, 'comments--topics')
105
+ if (!topicsCtrl) return ''
106
+ return topicsCtrl.mainTopicId ? String(topicsCtrl.mainTopicId) : ''
107
+ }
108
+
82
109
  function clearCommandText() {
83
110
  const pos = textarea.selectionStart
84
111
  const after = textarea.value.slice(pos)
@@ -4,6 +4,9 @@ import { $getCharacterOffsets, $getSelection, $isRangeSelection, $isTextNode, $i
4
4
  import { createInlineEditor } from './lexical_inline_editor'
5
5
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../creatives/tree_renderer'
6
6
  import { isProgressComplete, progressBaselineValueFrom, progressValueChangedFrom } from './creative_progress'
7
+ import { renderMarkdown } from '../lib/utils/markdown'
8
+ import { reconcileMarkdownSource } from './markdown_source_reconcile'
9
+ import { isHtmlEmpty } from './html_content_empty'
7
10
  import yaml from 'js-yaml'
8
11
  // Import Stimulus application from the global window (set by host app)
9
12
  const application = window.Stimulus
@@ -107,6 +110,16 @@ export function initializeCreativeRowEditor() {
107
110
  const metadataSaveBtn = document.getElementById('metadata-save-btn');
108
111
  const metadataCloseBtn = document.getElementById('metadata-popup-close');
109
112
 
113
+ // Markdown editor elements
114
+ const contentTypeInput = document.getElementById('inline-content-type');
115
+ const markdownSourceInput = document.getElementById('inline-markdown-source');
116
+ const markdownWrapper = document.getElementById('markdown-editor-wrapper');
117
+ const markdownTextarea = document.getElementById('markdown-editor-textarea');
118
+ const markdownPreview = document.getElementById('markdown-preview');
119
+ const toggleMarkdownBtn = document.getElementById('inline-toggle-markdown');
120
+ let markdownMode = false;
121
+ let markdownPreviewTimer = null;
122
+
110
123
  let lexicalEditor = null;
111
124
  if (editorContainer) {
112
125
  try {
@@ -239,6 +252,12 @@ export function initializeCreativeRowEditor() {
239
252
  if (Object.prototype.hasOwnProperty.call(data, 'origin_id')) {
240
253
  setRowDatasetValue(row, 'originId', data.origin_id ?? '');
241
254
  }
255
+ if (Object.prototype.hasOwnProperty.call(data, 'content_type')) {
256
+ setRowDatasetValue(row, 'contentType', data.content_type ?? '');
257
+ }
258
+ if (Object.prototype.hasOwnProperty.call(data, 'markdown_source')) {
259
+ setRowDatasetValue(row, 'markdownSource', data.markdown_source ?? '');
260
+ }
242
261
  if (Object.prototype.hasOwnProperty.call(data, 'has_children')) {
243
262
  if (data.has_children) {
244
263
  row.setAttribute('has-children', '');
@@ -277,16 +296,47 @@ export function initializeCreativeRowEditor() {
277
296
  description_raw_html: rawHtml,
278
297
  origin_id: row.dataset?.originId || '',
279
298
  parent_id: parentId,
280
- progress: Number.isNaN(progressValue) ? 0 : progressValue
299
+ progress: Number.isNaN(progressValue) ? 0 : progressValue,
300
+ content_type: row.dataset?.contentType || null,
301
+ markdown_source: row.dataset?.markdownSource || null
281
302
  };
282
303
  }
283
304
 
284
- function isHtmlEmpty(html) {
285
- if (!html) return true;
286
- const temp = document.createElement('div');
287
- temp.innerHTML = html;
288
- if (temp.querySelector('img')) return false;
289
- return (temp.textContent || '').trim().length === 0;
305
+ function isMarkdownEmpty(md) {
306
+ return !md || md.trim().length === 0;
307
+ }
308
+
309
+ function activateMarkdownMode(source) {
310
+ markdownMode = true;
311
+ if (contentTypeInput) contentTypeInput.value = 'markdown';
312
+ if (markdownTextarea) markdownTextarea.value = source || '';
313
+ if (markdownWrapper) markdownWrapper.style.display = '';
314
+ if (editorContainer) editorContainer.style.display = 'none';
315
+ if (markdownPreview) markdownPreview.innerHTML = source ? renderMarkdown(source) : '';
316
+ if (toggleMarkdownBtn) {
317
+ toggleMarkdownBtn.textContent = toggleMarkdownBtn.dataset.labelRichtext || 'Rich Text';
318
+ toggleMarkdownBtn.classList.add('active');
319
+ }
320
+ if (markdownTextarea) markdownTextarea.focus();
321
+ }
322
+
323
+ function deactivateMarkdownMode() {
324
+ markdownMode = false;
325
+ if (contentTypeInput) contentTypeInput.value = 'html';
326
+ if (markdownSourceInput) markdownSourceInput.value = '';
327
+ if (markdownWrapper) markdownWrapper.style.display = 'none';
328
+ if (editorContainer) editorContainer.style.display = '';
329
+ if (toggleMarkdownBtn) {
330
+ toggleMarkdownBtn.textContent = toggleMarkdownBtn.dataset.labelMarkdown || 'MD';
331
+ toggleMarkdownBtn.classList.remove('active');
332
+ }
333
+ }
334
+
335
+ function syncMarkdownToForm() {
336
+ if (!markdownMode) return;
337
+ const md = markdownTextarea ? markdownTextarea.value : '';
338
+ if (markdownSourceInput) markdownSourceInput.value = md;
339
+ if (descriptionInput) descriptionInput.value = renderMarkdown(md);
290
340
  }
291
341
 
292
342
  function applyCreativeData(data, tree) {
@@ -298,10 +348,21 @@ export function initializeCreativeRowEditor() {
298
348
  form.dataset.creativeId = creativeId;
299
349
  const content = data.description_raw_html || data.description || '';
300
350
  descriptionInput.value = content;
301
- lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
351
+
352
+ // Handle markdown vs rich text mode
353
+ const isMarkdown = data.content_type === 'markdown';
354
+ if (isMarkdown) {
355
+ activateMarkdownMode(data.markdown_source || '');
356
+ // Also load Lexical with HTML for fallback/switching
357
+ lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
358
+ } else {
359
+ deactivateMarkdownMode();
360
+ lexicalEditor.load(content, `creative-${creativeId}-${Date.now()}`);
361
+ }
362
+
302
363
  pendingSave = false;
303
364
  // Track original content for dirty state detection
304
- originalContent = content;
365
+ originalContent = isMarkdown ? (data.markdown_source || '') : content;
305
366
  isDirty = false;
306
367
  const progressNumber = Number(data.progress ?? 0);
307
368
  const normalizedProgress = Number.isNaN(progressNumber) ? 0 : progressNumber;
@@ -323,7 +384,9 @@ export function initializeCreativeRowEditor() {
323
384
  const effectiveParent = parentInput.value;
324
385
  if (unconvertBtn) unconvertBtn.style.display = effectiveParent ? '' : 'none';
325
386
  originalProgress = normalizedProgress;
326
- lexicalEditor.focus();
387
+ if (!isMarkdown) {
388
+ lexicalEditor.focus();
389
+ }
327
390
  updateActionButtonStates();
328
391
  // Reload metadata if the popup is open
329
392
  if (isMetadataPopupVisible()) {
@@ -973,7 +1036,13 @@ export function initializeCreativeRowEditor() {
973
1036
  if (saving) return savePromise;
974
1037
  clearTimeout(saveTimer);
975
1038
 
976
- if (isHtmlEmpty(descriptionInput.value)) {
1039
+ // Sync markdown form fields before saving
1040
+ if (markdownMode) syncMarkdownToForm();
1041
+
1042
+ const isEmpty = markdownMode
1043
+ ? isMarkdownEmpty(markdownTextarea?.value)
1044
+ : isHtmlEmpty(descriptionInput.value);
1045
+ if (isEmpty) {
977
1046
  pendingSave = false;
978
1047
  return Promise.resolve();
979
1048
  }
@@ -984,7 +1053,9 @@ export function initializeCreativeRowEditor() {
984
1053
  saving = true;
985
1054
 
986
1055
  // Capture values being saved to update dirty state on success
987
- const savedContent = descriptionInput.value;
1056
+ // NOTE: `let` (not `const`) when the server rewrites markdown_source
1057
+ // (e.g. data: URI → blob path) we reassign below.
1058
+ let savedContent = markdownMode ? (markdownTextarea?.value || '') : descriptionInput.value;
988
1059
  const shouldPersistProgress = progressValueChanged();
989
1060
  const savedProgress = shouldPersistProgress ? readProgressValue() : progressBaselineValueFrom(originalProgress);
990
1061
  const savedOriginId = originIdInput ? originIdInput.value : '';
@@ -1002,6 +1073,25 @@ export function initializeCreativeRowEditor() {
1002
1073
  return r.text().then(function (text) {
1003
1074
  try { return text ? JSON.parse(text) : {}; } catch (e) { return {}; }
1004
1075
  }).then(function (data) {
1076
+ // Sync rewritten markdown source back into the textarea/hidden input.
1077
+ // Server rewrites inline data: URIs in markdown_source to blob paths so
1078
+ // re-saves don't re-import the same image. If the user typed during the
1079
+ // request, merge the substitutions into the live textarea so the next
1080
+ // save still carries blob paths instead of re-importing the data URI.
1081
+ if (markdownMode && data && typeof data.markdown_source === 'string'
1082
+ && data.markdown_source !== savedContent && markdownTextarea) {
1083
+ const reconciled = reconcileMarkdownSource(
1084
+ savedContent, data.markdown_source, markdownTextarea.value
1085
+ );
1086
+ if (reconciled !== null && reconciled !== markdownTextarea.value) {
1087
+ markdownTextarea.value = reconciled;
1088
+ syncMarkdownToForm();
1089
+ }
1090
+ if (reconciled !== null) {
1091
+ savedContent = data.markdown_source;
1092
+ }
1093
+ }
1094
+
1005
1095
  // Update dirty state to reflect successful save
1006
1096
  originalContent = savedContent;
1007
1097
  if (shouldPersistProgress) {
@@ -1010,7 +1100,8 @@ export function initializeCreativeRowEditor() {
1010
1100
  originalOriginId = savedOriginId;
1011
1101
 
1012
1102
  // If current values match what was just saved, clear dirty flag
1013
- if (descriptionInput.value === savedContent &&
1103
+ const currentContent = markdownMode ? (markdownTextarea?.value || '') : descriptionInput.value;
1104
+ if (currentContent === savedContent &&
1014
1105
  readProgressValue() === savedProgress &&
1015
1106
  originIdInput.value === savedOriginId) {
1016
1107
  isDirty = false;
@@ -1197,6 +1288,8 @@ export function initializeCreativeRowEditor() {
1197
1288
 
1198
1289
  // CRITICAL: Capture ALL values BEFORE awaiting, because the editor may switch
1199
1290
  // to a different creative while we're waiting for uploads
1291
+ const isMarkdownSave = markdownMode;
1292
+ if (isMarkdownSave) syncMarkdownToForm();
1200
1293
  let currentContent = descriptionInput.value;
1201
1294
  let currentProgress = readProgressValue();
1202
1295
  let shouldPersistProgress = progressValueChanged();
@@ -1204,10 +1297,15 @@ export function initializeCreativeRowEditor() {
1204
1297
  const currentBeforeId = tree.previousElementSibling ? creativeIdFrom(tree.previousElementSibling) : '';
1205
1298
  const currentAfterId = tree.nextElementSibling ? creativeIdFrom(tree.nextElementSibling) : '';
1206
1299
  const startCreativeId = creativeId;
1300
+ let capturedMarkdownSource = isMarkdownSave ? (markdownTextarea?.value || '') : '';
1301
+ const capturedContentType = isMarkdownSave ? 'markdown' : 'html';
1207
1302
 
1208
1303
  // Prevent saving empty content, matching saveForm behavior
1209
1304
  // This avoids overwriting existing descriptions with empty strings during quick navigation
1210
- if (isHtmlEmpty(currentContent)) {
1305
+ const isEmpty = isMarkdownSave
1306
+ ? isMarkdownEmpty(capturedMarkdownSource)
1307
+ : isHtmlEmpty(currentContent);
1308
+ if (isEmpty) {
1211
1309
  pendingSave = false;
1212
1310
  return;
1213
1311
  }
@@ -1217,8 +1315,15 @@ export function initializeCreativeRowEditor() {
1217
1315
  await waitForUploads();
1218
1316
 
1219
1317
  // If we are still on the same creative (e.g. move awaited us), refresh the content
1220
- // This ensures we capture the final HTML with signed IDs instead of blob URLs
1318
+ // This ensures we capture the final HTML with signed IDs instead of blob URLs.
1319
+ // Markdown saves regenerate description from creative[markdown_source] server-side,
1320
+ // so we must re-sync and re-capture the latest textarea value too — otherwise edits
1321
+ // made during the upload wait get overwritten by the stale pre-wait source.
1221
1322
  if (form.dataset.creativeId === startCreativeId) {
1323
+ if (isMarkdownSave) {
1324
+ syncMarkdownToForm();
1325
+ capturedMarkdownSource = markdownTextarea?.value || '';
1326
+ }
1222
1327
  currentContent = descriptionInput.value;
1223
1328
  currentProgress = readProgressValue();
1224
1329
  shouldPersistProgress = progressValueChanged();
@@ -1228,8 +1333,12 @@ export function initializeCreativeRowEditor() {
1228
1333
  // Note: before_id and after_id must be top-level params, not nested under creative[]
1229
1334
  // because CreativesController reads params[:before_id] and params[:after_id] for positioning
1230
1335
  const body = {
1231
- 'creative[description]': currentContent
1336
+ 'creative[description]': currentContent,
1337
+ 'creative[content_type_input]': capturedContentType
1232
1338
  };
1339
+ if (isMarkdownSave) {
1340
+ body['creative[markdown_source]'] = capturedMarkdownSource;
1341
+ }
1233
1342
 
1234
1343
  if (shouldPersistProgress) {
1235
1344
  body['creative[progress]'] = currentProgress;
@@ -1257,6 +1366,8 @@ export function initializeCreativeRowEditor() {
1257
1366
  if (shouldPersistProgress) {
1258
1367
  row.dataset.progressValue = String(currentProgress);
1259
1368
  }
1369
+ row.dataset.contentType = capturedContentType;
1370
+ row.dataset.markdownSource = isMarkdownSave ? capturedMarkdownSource : '';
1260
1371
  if (currentParentId) {
1261
1372
  tree.dataset.parentId = currentParentId;
1262
1373
  row.parentId = currentParentId;
@@ -1279,12 +1390,49 @@ export function initializeCreativeRowEditor() {
1279
1390
 
1280
1391
  // Queue the save request
1281
1392
  // Store deletedAttachmentIds as data, not as callback, so it can be serialized
1393
+ // Capture per-enqueue values for the onSuccess closure so concurrent edits
1394
+ // on a different creative don't get clobbered when the response comes back.
1395
+ const onSuccessCreativeId = startCreativeId;
1396
+ const onSuccessSavedMarkdown = isMarkdownSave ? capturedMarkdownSource : null;
1397
+ const onSuccessTree = tree;
1282
1398
  apiQueue.enqueue({
1283
1399
  path: `/creatives/${creativeId}`,
1284
1400
  method: 'PATCH',
1285
1401
  body: body,
1286
1402
  dedupeKey: `creative_${creativeId}`,
1287
- deletedAttachmentIds: deletedAttachmentIds // Store as data for serialization
1403
+ deletedAttachmentIds: deletedAttachmentIds, // Store as data for serialization
1404
+ onSuccess: function (data) {
1405
+ if (!isMarkdownSave || !data || typeof data.markdown_source !== 'string') return;
1406
+ if (data.markdown_source === onSuccessSavedMarkdown) return;
1407
+
1408
+ // Update the row dataset cache regardless of which creative is active now,
1409
+ // so a later loadCreative() for this row picks up the rewritten source.
1410
+ if (onSuccessTree) {
1411
+ const row = treeRowElement(onSuccessTree);
1412
+ if (row && row.dataset.markdownSource === onSuccessSavedMarkdown) {
1413
+ row.dataset.markdownSource = data.markdown_source;
1414
+ row.requestUpdate?.();
1415
+ }
1416
+ }
1417
+
1418
+ // Merge the data: URI -> blob path substitutions into the live textarea,
1419
+ // even if the user typed during the queued save. We still require the
1420
+ // same creative to be open (race-safe across editor switches).
1421
+ if (form.dataset.creativeId === onSuccessCreativeId
1422
+ && markdownMode
1423
+ && markdownTextarea) {
1424
+ const reconciled = reconcileMarkdownSource(
1425
+ onSuccessSavedMarkdown, data.markdown_source, markdownTextarea.value
1426
+ );
1427
+ if (reconciled !== null && reconciled !== markdownTextarea.value) {
1428
+ markdownTextarea.value = reconciled;
1429
+ syncMarkdownToForm();
1430
+ }
1431
+ if (reconciled !== null) {
1432
+ originalContent = data.markdown_source;
1433
+ }
1434
+ }
1435
+ }
1288
1436
  });
1289
1437
  // console.warn('apiQueue.enqueue disabled for debugging');
1290
1438
 
@@ -1739,6 +1887,7 @@ export function initializeCreativeRowEditor() {
1739
1887
  afterInput.value = afterId || '';
1740
1888
  if (childInput) childInput.value = childId || '';
1741
1889
  resetOriginTracking();
1890
+ deactivateMarkdownMode();
1742
1891
  descriptionInput.value = '';
1743
1892
  lexicalEditor.reset(`new-${Date.now()}`);
1744
1893
  setProgressState(0);
@@ -1781,12 +1930,25 @@ export function initializeCreativeRowEditor() {
1781
1930
  }
1782
1931
 
1783
1932
  function onLexicalChange(html) {
1933
+ if (markdownMode) return; // Ignore Lexical changes in markdown mode
1784
1934
  descriptionInput.value = html;
1785
1935
  // Mark as dirty if content changed from original
1786
1936
  isDirty = (html !== originalContent);
1787
1937
  scheduleSave();
1788
1938
  }
1789
1939
 
1940
+ function onMarkdownTextareaInput() {
1941
+ const md = markdownTextarea.value;
1942
+ syncMarkdownToForm();
1943
+ isDirty = (md !== originalContent);
1944
+ scheduleSave();
1945
+ // Debounced live preview
1946
+ clearTimeout(markdownPreviewTimer);
1947
+ markdownPreviewTimer = setTimeout(() => {
1948
+ if (markdownPreview) markdownPreview.innerHTML = renderMarkdown(md);
1949
+ }, 300);
1950
+ }
1951
+
1790
1952
  // Intercepts Shift+Enter via capture-phase keydown on Lexical's root element.
1791
1953
  // Returning true triggers preventDefault + stopImmediatePropagation.
1792
1954
  // Shift+Enter → addNew (save & add next)
@@ -2174,5 +2336,53 @@ export function initializeCreativeRowEditor() {
2174
2336
  });
2175
2337
  }
2176
2338
  }
2339
+
2340
+ // Markdown toggle button
2341
+ if (toggleMarkdownBtn) {
2342
+ toggleMarkdownBtn.addEventListener('click', function () {
2343
+ if (markdownMode) {
2344
+ // Switching from Markdown → Rich Text
2345
+ const confirmMsg = toggleMarkdownBtn.dataset.confirmToRichtext;
2346
+ if (confirmMsg && !confirm(confirmMsg)) return;
2347
+ const md = markdownTextarea?.value || '';
2348
+ const html = md ? renderMarkdown(md) : '';
2349
+ deactivateMarkdownMode();
2350
+ descriptionInput.value = html;
2351
+ lexicalEditor.load(html, `creative-switch-${Date.now()}`);
2352
+ lexicalEditor.focus();
2353
+ isDirty = true;
2354
+ scheduleSave();
2355
+ } else {
2356
+ // Switching from Rich Text → Markdown
2357
+ const currentHtml = descriptionInput.value || '';
2358
+ if (!isHtmlEmpty(currentHtml)) {
2359
+ const confirmMsg = toggleMarkdownBtn.dataset.confirmToMarkdown;
2360
+ if (confirmMsg && !confirm(confirmMsg)) return;
2361
+ }
2362
+ activateMarkdownMode('');
2363
+ isDirty = true;
2364
+ scheduleSave();
2365
+ }
2366
+ });
2367
+ }
2368
+
2369
+ // Markdown textarea input handler
2370
+ if (markdownTextarea) {
2371
+ markdownTextarea.addEventListener('input', onMarkdownTextareaInput);
2372
+
2373
+ // Support keyboard shortcuts in markdown textarea
2374
+ markdownTextarea.addEventListener('keydown', function (event) {
2375
+ if (event.key === 'Escape') {
2376
+ event.preventDefault();
2377
+ hideCurrent();
2378
+ return;
2379
+ }
2380
+ // Shift+Enter → add new sibling (same as Lexical)
2381
+ if (event.key === 'Enter' && event.shiftKey) {
2382
+ event.preventDefault();
2383
+ addNew();
2384
+ }
2385
+ });
2386
+ }
2177
2387
  });
2178
2388
  }
@@ -0,0 +1,12 @@
1
+ // True when an HTML fragment has no user-visible content. Treats inline
2
+ // images and attachments (action-text-attachment / figure.attachment /
3
+ // data-trix-attachment) as content so image-only or attachment-only bodies
4
+ // don't get discarded silently when switching editor modes.
5
+ export function isHtmlEmpty(html, doc = typeof document !== 'undefined' ? document : null) {
6
+ if (!html) return true;
7
+ if (!doc) return html.replace(/<[^>]*>/g, '').trim().length === 0;
8
+ const temp = doc.createElement('div');
9
+ temp.innerHTML = html;
10
+ if (temp.querySelector('img, action-text-attachment, figure.attachment, [data-trix-attachment]')) return false;
11
+ return (temp.textContent || '').trim().length === 0;
12
+ }
@@ -0,0 +1,53 @@
1
+ // Reconcile a server-rewritten markdown source with the current textarea value
2
+ // when the user typed during the save. The server's rewrite is a pure
3
+ // data: URI -> blob path substitution on what we sent (`savedContent`).
4
+ // If every data: URI we sent is still present in `currentValue`, apply the
5
+ // same substitutions there and return the merged value; otherwise return null.
6
+ export function reconcileMarkdownSource(savedContent, rewritten, currentValue) {
7
+ if (typeof savedContent !== 'string' || typeof rewritten !== 'string'
8
+ || typeof currentValue !== 'string') return null;
9
+ if (rewritten === savedContent) return null;
10
+ if (currentValue === savedContent) return rewritten;
11
+
12
+ const dataUriRegex = /data:image\/[^\s)>"'`\]]+/g;
13
+ const matches = [];
14
+ let m;
15
+ while ((m = dataUriRegex.exec(savedContent)) !== null) {
16
+ matches.push({ uri: m[0], start: m.index, end: m.index + m[0].length });
17
+ }
18
+ if (matches.length === 0) return null;
19
+
20
+ const pairs = [];
21
+ let savedPos = 0;
22
+ let rewrittenPos = 0;
23
+ for (let i = 0; i < matches.length; i++) {
24
+ const match = matches[i];
25
+ const textLen = match.start - savedPos;
26
+ if (rewritten.slice(rewrittenPos, rewrittenPos + textLen)
27
+ !== savedContent.slice(savedPos, match.start)) return null;
28
+ rewrittenPos += textLen;
29
+ savedPos = match.end;
30
+
31
+ const nextStart = (i + 1 < matches.length) ? matches[i + 1].start : savedContent.length;
32
+ const tail = savedContent.slice(savedPos, nextStart);
33
+ let blobEnd;
34
+ if (tail.length === 0) {
35
+ blobEnd = rewritten.length;
36
+ } else {
37
+ blobEnd = rewritten.indexOf(tail, rewrittenPos);
38
+ if (blobEnd === -1) return null;
39
+ }
40
+ pairs.push({ from: match.uri, to: rewritten.slice(rewrittenPos, blobEnd) });
41
+ rewrittenPos = blobEnd;
42
+ }
43
+ if (rewritten.slice(rewrittenPos) !== savedContent.slice(savedPos)) return null;
44
+
45
+ let result = currentValue;
46
+ for (const pair of pairs) {
47
+ if (pair.from === pair.to) continue;
48
+ const idx = result.indexOf(pair.from);
49
+ if (idx === -1) return null;
50
+ result = result.slice(0, idx) + pair.to + result.slice(idx + pair.from.length);
51
+ }
52
+ return result;
53
+ }
@@ -9,12 +9,65 @@ module Collavre
9
9
  task = agent_id_or_task
10
10
  return if task.reload.status == "cancelled"
11
11
 
12
- task.update!(status: "running")
13
12
  agent = task.agent
13
+
14
+ # Guard: same offline-session check as the agent_id branch below.
15
+ # Queued Claude Channel tasks resumed via Orchestration::AgentOrchestrator
16
+ # .dequeue_next_for_topic enter this branch as AiAgentJob.perform_later(task).
17
+ # If AgentChannel#unsubscribed cleared routing_expression while the
18
+ # task was queued (WS drop without DELETE /agent/:id, e.g. SIGKILL or
19
+ # network blip past the reconnect grace), and another completion later
20
+ # drains the queue, this task would otherwise be promoted to running →
21
+ # delegated and broadcast to a clientless agent:user:<id> stream —
22
+ # held until stuck recovery.
23
+ if agent.claude_channel_agent? && agent.routing_expression.blank?
24
+ Rails.logger.info(
25
+ "[AiAgentJob] Skipping resumed Claude Channel task #{task.id}: " \
26
+ "session offline (routing_expression blank)"
27
+ )
28
+ # Workflow subtasks created by WorkflowExecutor carry parent_task_id and
29
+ # no topic. If we only cancel the child and return, the parent workflow
30
+ # stays "running" with its current/pending creative state forever — no
31
+ # rescue path runs because we never raise. Mirror the StandardError
32
+ # rescue below: fail the child and notify the parent so the workflow
33
+ # transitions to "failed" with a failure_reason.
34
+ if task.parent_task_id.present?
35
+ task.update!(status: "failed")
36
+ Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
37
+ task,
38
+ error_message: "Claude Channel session offline before dispatch"
39
+ )
40
+ else
41
+ task.update!(status: "cancelled")
42
+ end
43
+ if task.trigger_event_payload&.key?("topic")
44
+ Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
45
+ end
46
+ return
47
+ end
48
+
49
+ task.update!(status: "running")
14
50
  else
15
51
  # Create new task
16
52
  agent = User.find(agent_id_or_task)
17
53
 
54
+ # Guard: skip if the Claude Channel session has unregistered (or its WS
55
+ # dropped) during the window between Scheduler enqueue and this job
56
+ # firing. AgentsController#destroy / AgentChannel#unsubscribed clear
57
+ # routing_expression on the per-session ai_user, so a blank value here
58
+ # means there is no live MCP client to receive the dispatch. Without
59
+ # this guard, a :delayed (busy / rate-limited) enqueue from
60
+ # Scheduler#evaluate would materialize a fresh Task, flip it to
61
+ # "delegated", and broadcast to a clientless agent:user:<id> stream
62
+ # — holding the topic/agent slot until stuck recovery.
63
+ if agent.claude_channel_agent? && agent.routing_expression.blank?
64
+ Rails.logger.info(
65
+ "[AiAgentJob] Skipping Claude Channel job for agent #{agent.id}: " \
66
+ "session offline (routing_expression blank, event=#{event_name})"
67
+ )
68
+ return
69
+ end
70
+
18
71
  # Guard: skip if there's already a running task for the same agent + comment
19
72
  comment_id = context&.dig("comment", "id")
20
73
  if comment_id && Task.duplicate_running_for_comment?(agent.id, comment_id)
@@ -48,15 +101,48 @@ module Collavre
48
101
 
49
102
  # Reserve resources before starting work
50
103
  tracker = Orchestration::ResourceTracker.for(agent)
51
- resource_id = job_id || task.id
104
+ # Claude Channel tasks live past this job (MCP reply happens later), so
105
+ # reserve under the stable task.id — that's the key reply / cancel /
106
+ # stuck-recovery will use to release the slot.
107
+ is_claude_channel_agent = agent.claude_channel_agent?
108
+ resource_id = is_claude_channel_agent ? task.id : (job_id || task.id)
52
109
  tracker.reserve!(resource_id)
53
110
  should_release = true
54
111
 
55
112
  begin
113
+ # For Claude Channel agents, transition to "delegated" BEFORE dispatching.
114
+ # The MCP client can receive the broadcast and POST /reply on a different
115
+ # thread before AiAgentService#call returns; the reply handler only looks
116
+ # for status: "delegated" tasks, so a late update! would leave the
117
+ # already-answered task stuck in delegated until stuck recovery.
118
+ if is_claude_channel_agent
119
+ # Atomic running -> delegated transition. If AgentsController#destroy
120
+ # races us between reserve! above and this line and flips the task
121
+ # to "cancelled", the WHERE filter excludes us, rows_updated == 0,
122
+ # we skip dispatch, and the ensure block releases the slot. A
123
+ # separate reload + update! would let the cancel slip in between.
124
+ rows_updated = Task.where(id: task.id, status: "running").update_all(
125
+ status: "delegated", updated_at: Time.current
126
+ )
127
+ if rows_updated.zero?
128
+ task.reload
129
+ Rails.logger.info(
130
+ "[AiAgentJob] Claude Channel task #{task.id} not in running state " \
131
+ "(status=#{task.status}); skipping dispatch"
132
+ )
133
+ return
134
+ end
135
+ task.reload
136
+ end
137
+
56
138
  response_content = AiAgentService.new(task).call
57
139
 
140
+ # Claude Channel agents delegate via MCP; no immediate response expected
141
+ if is_claude_channel_agent
142
+ # Hold agent capacity until reply / cancel / stuck-recovery releases it.
143
+ should_release = false
58
144
  # Workflow subtasks with empty responses should retry, then fail
59
- if task.parent_task_id.present? && response_content.blank?
145
+ elsif task.parent_task_id.present? && response_content.blank?
60
146
  max_retries = 2
61
147
  current_retry = task.retry_count || 0
62
148