collavre 0.20.2 → 0.21.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 (109) 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 +83 -0
  5. data/app/assets/stylesheets/collavre/landing.css +507 -0
  6. data/app/channels/collavre/comments_presence_channel.rb +7 -0
  7. data/app/controllers/collavre/admin/integrations_controller.rb +82 -0
  8. data/app/controllers/collavre/admin/settings_controller.rb +22 -17
  9. data/app/controllers/collavre/application_controller.rb +27 -0
  10. data/app/controllers/collavre/channels_controller.rb +23 -0
  11. data/app/controllers/collavre/creatives_controller.rb +50 -6
  12. data/app/controllers/collavre/landing_controller.rb +8 -0
  13. data/app/controllers/collavre/passwords_controller.rb +1 -0
  14. data/app/controllers/collavre/public_assets_controller.rb +24 -0
  15. data/app/controllers/collavre/topics_controller.rb +21 -30
  16. data/app/helpers/collavre/comments_helper.rb +7 -0
  17. data/app/helpers/collavre/public_assets_helper.rb +14 -0
  18. data/app/javascript/controllers/comment_controller.js +9 -0
  19. data/app/javascript/controllers/comments/form_controller.js +4 -0
  20. data/app/javascript/controllers/comments/list_controller.js +10 -7
  21. data/app/javascript/controllers/comments/popup_controller.js +9 -0
  22. data/app/javascript/controllers/comments/presence_controller.js +83 -1
  23. data/app/javascript/controllers/comments/topics_controller.js +15 -0
  24. data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
  25. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
  26. data/app/javascript/controllers/creatives/sync_controller.js +30 -9
  27. data/app/javascript/controllers/creatives/tree_controller.js +23 -0
  28. data/app/javascript/controllers/index.js +4 -1
  29. data/app/javascript/controllers/landing_video_controller.js +53 -0
  30. data/app/javascript/creatives/tree_renderer.js +6 -0
  31. data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
  32. data/app/javascript/lib/api/queue_manager.js +17 -5
  33. data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
  34. data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
  35. data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
  36. data/app/javascript/modules/command_args_form.js +22 -4
  37. data/app/javascript/modules/command_menu.js +27 -0
  38. data/app/javascript/modules/creative_row_editor.js +227 -17
  39. data/app/javascript/modules/html_content_empty.js +12 -0
  40. data/app/javascript/modules/markdown_source_reconcile.js +53 -0
  41. data/app/jobs/collavre/drop_trigger_job.rb +37 -8
  42. data/app/mailers/collavre/application_mailer.rb +1 -1
  43. data/app/models/collavre/channel/injected_message.rb +5 -0
  44. data/app/models/collavre/channel.rb +87 -0
  45. data/app/models/collavre/creative/describable.rb +65 -3
  46. data/app/models/collavre/creative.rb +2 -0
  47. data/app/models/collavre/integration_setting.rb +35 -0
  48. data/app/models/collavre/preview_channel.rb +93 -0
  49. data/app/models/collavre/system_setting.rb +13 -2
  50. data/app/models/collavre/topic.rb +3 -25
  51. data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
  52. data/app/services/collavre/ai_client.rb +3 -3
  53. data/app/services/collavre/channel_attacher.rb +58 -0
  54. data/app/services/collavre/comments/mcp_command.rb +31 -1
  55. data/app/services/collavre/creatives/tree_builder.rb +7 -3
  56. data/app/services/collavre/google_calendar_service.rb +4 -2
  57. data/app/services/collavre/markdown_converter.rb +130 -15
  58. data/app/services/collavre/markdown_importer.rb +7 -2
  59. data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
  60. data/app/services/collavre/tools/creative_attach_files_service.rb +96 -0
  61. data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
  62. data/app/services/collavre/tools/creative_remove_attachment_service.rb +35 -0
  63. data/app/services/collavre/tools/permission_denied_error.rb +9 -0
  64. data/app/services/collavre/tools/preview_attach_service.rb +128 -0
  65. data/app/services/collavre/tools/preview_detach_service.rb +61 -0
  66. data/app/services/collavre/tools/topic_authorizer.rb +24 -0
  67. data/app/services/collavre/topic_branch_service.rb +34 -26
  68. data/app/views/admin/shared/_tabs.html.erb +1 -0
  69. data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
  70. data/app/views/collavre/admin/integrations/_setting_row.html.erb +54 -0
  71. data/app/views/collavre/admin/integrations/index.html.erb +42 -0
  72. data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
  73. data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
  74. data/app/views/collavre/comments/_comment.html.erb +6 -1
  75. data/app/views/collavre/comments/_comments_popup.html.erb +1 -0
  76. data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
  77. data/app/views/collavre/creatives/index.html.erb +10 -2
  78. data/app/views/collavre/landing/show.html.erb +130 -0
  79. data/app/views/layouts/collavre/landing.html.erb +33 -0
  80. data/config/locales/admin.en.yml +4 -2
  81. data/config/locales/admin.ko.yml +4 -2
  82. data/config/locales/channels.en.yml +11 -0
  83. data/config/locales/channels.ko.yml +11 -0
  84. data/config/locales/comments.en.yml +2 -0
  85. data/config/locales/comments.ko.yml +2 -0
  86. data/config/locales/creatives.en.yml +9 -0
  87. data/config/locales/creatives.ko.yml +8 -0
  88. data/config/locales/integrations.en.yml +44 -0
  89. data/config/locales/integrations.ko.yml +44 -0
  90. data/config/locales/landing.en.yml +51 -0
  91. data/config/locales/landing.ko.yml +51 -0
  92. data/config/routes.rb +18 -0
  93. data/db/migrate/20260526000000_create_channels.rb +42 -0
  94. data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
  95. data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
  96. data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
  97. data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
  98. data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
  99. data/db/seeds.rb +19 -0
  100. data/lib/collavre/aws_credentials.rb +75 -0
  101. data/lib/collavre/engine.rb +51 -0
  102. data/lib/collavre/integration_settings/key_definition.rb +29 -0
  103. data/lib/collavre/integration_settings/registry.rb +55 -0
  104. data/lib/collavre/integration_settings/resolver.rb +71 -0
  105. data/lib/collavre/integration_settings.rb +46 -0
  106. data/lib/collavre/ses_settings_interceptor.rb +72 -0
  107. data/lib/collavre/version.rb +1 -1
  108. data/lib/collavre.rb +3 -0
  109. metadata +52 -1
@@ -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
+ }
@@ -8,6 +8,8 @@ module Collavre
8
8
  # This triggers retry_on instead of silently succeeding.
9
9
  class DispatchFailedError < StandardError; end
10
10
 
11
+ DROP_TRIGGER_TOPIC_NAME = "Drop Trigger"
12
+
11
13
  retry_on DispatchFailedError, wait: 5.seconds, attempts: 3
12
14
 
13
15
  # End-to-end idempotent: safe to retry at any point.
@@ -57,9 +59,8 @@ module Collavre
57
59
  private
58
60
 
59
61
  def post_trigger_failure_notice(child, parent)
60
- topic = child.topics.find_or_create_by!(name: "Drop Trigger") do |t|
61
- t.user = child.user
62
- end
62
+ topic = child.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME) ||
63
+ create_drop_trigger_topic(child)
63
64
  post_system_notice(child, topic, I18n.t(
64
65
  "collavre.drop_trigger.no_agent",
65
66
  parent_description: parent.creative_snippet
@@ -82,17 +83,45 @@ module Collavre
82
83
  end
83
84
 
84
85
  def find_or_create_trigger_topic(creative, agent)
85
- topic = creative.topics.find_by(name: "Drop Trigger")
86
+ topic = creative.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME)
86
87
  return topic if topic
87
88
 
88
- topic = creative.topics.create!(
89
- name: "Drop Trigger",
90
- user: creative.user
91
- )
89
+ topic = create_drop_trigger_topic(creative)
92
90
  topic.set_primary_agent!(agent)
93
91
  topic
94
92
  end
95
93
 
94
+ # Creates the Drop Trigger topic, branching from the creative's Main topic
95
+ # when Main has messages. Equivalent to the user manually selecting every
96
+ # Main message and pressing "branch" — only the resulting topic name is
97
+ # fixed to "Drop Trigger".
98
+ def create_drop_trigger_topic(creative)
99
+ main = creative.main_topic(fallback_user: creative.user)
100
+ # Mirror the manual "select all → branch" flow: only comments the owner
101
+ # can see are copied. enforce_limit: false bypasses the UI's 100-comment
102
+ # selection cap so full Main history transfers regardless of length.
103
+ main_comment_ids = main.comments
104
+ .visible_to(creative.user)
105
+ .order(:created_at)
106
+ .pluck(:id)
107
+
108
+ if main_comment_ids.any?
109
+ # auto_select: false prevents the broadcast from hijacking the owner's
110
+ # current topic selection while this background job runs.
111
+ TopicBranchService.new(
112
+ creative: creative,
113
+ user: creative.user,
114
+ source_topic: main,
115
+ name: DROP_TRIGGER_TOPIC_NAME
116
+ ).call(comment_ids: main_comment_ids, enforce_limit: false, auto_select: false)
117
+ else
118
+ creative.topics.create!(
119
+ name: DROP_TRIGGER_TOPIC_NAME,
120
+ user: creative.user
121
+ )
122
+ end
123
+ end
124
+
96
125
  def trigger_content_key(child, parent)
97
126
  I18n.t(
98
127
  "collavre.drop_trigger.child_entered",
@@ -1,6 +1,6 @@
1
1
  module Collavre
2
2
  class ApplicationMailer < ActionMailer::Base
3
- default from: ENV.fetch("DEFAULT_MAILER_FROM", "no-reply@example.com")
3
+ default from: -> { Collavre::IntegrationSettings.fetch(:default_mailer_from, default: "no-reply@example.com") }
4
4
  layout "mailer"
5
5
 
6
6
  private
@@ -0,0 +1,5 @@
1
+ module Collavre
2
+ class Channel
3
+ InjectedMessage = Data.define(:speaker, :message, :label, :link)
4
+ end
5
+ end