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
@@ -1,6 +1,17 @@
1
1
  import CommonPopupController from './common_popup_controller'
2
2
  import creativesApi from '../lib/api/creatives'
3
3
 
4
+ // Minimum characters before a text search fires. Below this the popup shows the
5
+ // browsable mini-tree instead (empty input => tree, >= MIN_QUERY chars => search).
6
+ const MIN_QUERY = 2
7
+
8
+ // Chevron icons matched to the main creative tree (creative_tree_row.js#_toggleIcon)
9
+ // so the mini-tree expand/collapse affordance is visually identical.
10
+ const CHEVRON_COLLAPSED =
11
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6L15 12L9 18"/></svg>'
12
+ const CHEVRON_EXPANDED =
13
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9L12 15L18 9"/></svg>'
14
+
4
15
  export default class extends CommonPopupController {
5
16
  static targets = ['input', 'list', 'close']
6
17
 
@@ -8,6 +19,13 @@ export default class extends CommonPopupController {
8
19
  super.connect()
9
20
  this._debounceTimer = null
10
21
  this._searchToken = 0
22
+ this._mode = 'tree'
23
+ this._rootNodes = null
24
+ this._activeEl = null
25
+ // Whether the user has explicitly navigated the active row (arrow keys).
26
+ // Gates Tab-to-select so Tab still moves focus out of the initial browse
27
+ // state, where the first row is auto-highlighted but unchosen.
28
+ this._navigated = false
11
29
  this.inputTarget.addEventListener('input', this._debouncedSearch.bind(this))
12
30
  this.inputTarget.addEventListener('keydown', this.handleInputKeydown.bind(this))
13
31
  this.closeTarget.addEventListener('click', () => this.close())
@@ -17,72 +35,427 @@ export default class extends CommonPopupController {
17
35
  }
18
36
 
19
37
  disconnect() {
20
- if (this._debounceTimer) {
21
- clearTimeout(this._debounceTimer)
22
- this._debounceTimer = null
23
- }
38
+ this._clearDebounce()
24
39
  super.disconnect()
25
40
  }
26
41
 
27
42
  open(anchorRect, onSelectCallback, onCloseCallback) {
28
43
  this.onSelectCallback = onSelectCallback
29
44
  this.onCloseCallback = onCloseCallback
30
- this.setItems([])
45
+ this._mode = 'tree'
46
+ this._rootNodes = null
47
+ this._activeEl = null
31
48
  this.inputTarget.value = ''
49
+ // Clear any CommonPopup item state; we render our own DOM into the list.
50
+ this.popup.setItems([])
32
51
  super.open(anchorRect)
33
52
 
34
53
  requestAnimationFrame(() => {
35
54
  this.inputTarget.focus()
36
55
  })
56
+
57
+ this._showTree()
37
58
  }
38
59
 
39
60
  close() {
61
+ this._clearDebounce()
62
+ super.close()
63
+ }
64
+
65
+ _clearDebounce() {
40
66
  if (this._debounceTimer) {
41
67
  clearTimeout(this._debounceTimer)
42
68
  this._debounceTimer = null
43
69
  }
44
- // super.close() calls popup.hide(), which triggers dispatchClose,
45
- // where we now handle the callback. So we just need to call super.close().
46
- super.close()
47
70
  }
48
71
 
49
72
  handleInputKeydown(event) {
50
- // Delegate to CommonPopup for navigation
51
- if (this.handleKey(event)) return
52
-
53
- // Special handling for this specific popup
73
+ // CommonPopup's key handling is item-list based and we don't populate it,
74
+ // so it is a no-op here. We drive navigation over our own rendered rows.
54
75
  if (event.key === 'Escape') {
55
76
  this.close()
77
+ return
78
+ }
79
+
80
+ if (event.key === 'ArrowDown') {
81
+ event.preventDefault()
82
+ this._moveActive(1)
83
+ return
84
+ }
85
+
86
+ if (event.key === 'ArrowUp') {
87
+ event.preventDefault()
88
+ this._moveActive(-1)
89
+ return
90
+ }
91
+
92
+ // Enter is a deliberate select key: always activate the highlighted row.
93
+ if (event.key === 'Enter' && this._activeEl) {
94
+ event.preventDefault()
95
+ this._activateRow(this._activeEl)
96
+ return
97
+ }
98
+
99
+ // Tab-to-select only when the choice is explicit — an actual search result
100
+ // or after the user has navigated the tree. In the initial browse state the
101
+ // first row is auto-highlighted but unchosen, so let Tab move focus instead
102
+ // of linking a creative the user never picked.
103
+ if (event.key === 'Tab' && this._activeEl && (this._mode === 'search' || this._navigated)) {
104
+ event.preventDefault()
105
+ this._activateRow(this._activeEl)
106
+ return
107
+ }
108
+
109
+ if (this._mode === 'tree' && this._activeEl) {
110
+ if (event.key === 'ArrowRight') {
111
+ event.preventDefault()
112
+ this._navigated = true
113
+ this._expandNode(this._activeEl.closest('.link-tree-item'))
114
+ return
115
+ }
116
+ if (event.key === 'ArrowLeft') {
117
+ event.preventDefault()
118
+ this._navigated = true
119
+ this._collapseNode(this._activeEl.closest('.link-tree-item'))
120
+ }
56
121
  }
57
122
  }
58
123
 
59
124
  _debouncedSearch() {
60
- if (this._debounceTimer) clearTimeout(this._debounceTimer)
125
+ this._clearDebounce()
61
126
  this._debounceTimer = setTimeout(() => this.search(), 300)
62
127
  }
63
128
 
64
129
  search() {
65
130
  const query = this.inputTarget.value.trim()
66
- if (query.length < 3) {
131
+ if (query.length < MIN_QUERY) {
67
132
  this._searchToken++
68
- this.setItems([])
133
+ this._showTree()
69
134
  return
70
135
  }
71
136
 
137
+ this._mode = 'search'
72
138
  const token = ++this._searchToken
73
139
  creativesApi.search(query, { simple: true })
74
140
  .then((results) => {
75
141
  // Discard stale responses if input changed since this request
76
142
  if (token !== this._searchToken) return
143
+ this._renderSearchResults(Array.isArray(results) ? results : [])
144
+ })
145
+ .catch(() => {
146
+ if (token === this._searchToken) this._renderSearchResults([])
147
+ })
148
+ }
149
+
150
+ // --- Tree (browse) mode --------------------------------------------------
151
+
152
+ _showTree() {
153
+ this._mode = 'tree'
154
+ // Entering a fresh browse state (open, or query cleared back to browse):
155
+ // the auto-highlighted first row is unchosen until the user navigates.
156
+ this._navigated = false
157
+ if (this._rootNodes) {
158
+ this._renderTree(this._rootNodes)
159
+ return
160
+ }
161
+
162
+ this._renderMessage(this._text('loadingText'))
163
+ const token = ++this._searchToken
164
+ creativesApi.browse(null)
165
+ .then((nodes) => {
166
+ if (token !== this._searchToken) return
167
+ this._rootNodes = Array.isArray(nodes) ? nodes : []
168
+ this._renderTree(this._rootNodes)
169
+ })
170
+ .catch(() => {
171
+ if (token === this._searchToken) this._renderMessage(this._text('emptyText'))
172
+ })
173
+ }
174
+
175
+ _renderTree(nodes) {
176
+ this.listTarget.innerHTML = ''
177
+ if (!nodes || nodes.length === 0) {
178
+ this._renderMessage(this._text('emptyText'))
179
+ return
180
+ }
181
+ nodes.forEach((node) => this.listTarget.appendChild(this._buildTreeItem(node, 0)))
182
+ this._resetActive()
183
+ }
184
+
185
+ _buildTreeItem(node, level) {
186
+ const li = document.createElement('li')
187
+ li.className = 'link-tree-item'
188
+ li.dataset.id = String(node.id)
189
+ li.dataset.level = String(level)
190
+ li.dataset.loaded = '0'
191
+ // Linked-creative shells render under the user's shell id, but search
192
+ // breadcrumbs carry the effective origin id. Record the origin id so
193
+ // _findItem can resolve a breadcrumb's root crumb back to this shell.
194
+ if (node.origin_id) li.dataset.originId = String(node.origin_id)
195
+
196
+ const row = document.createElement('div')
197
+ row.className = 'link-tree-row'
198
+ row.setAttribute('data-pick-row', '')
199
+ row.dataset.id = String(node.id)
200
+ row.style.paddingLeft = `${level * 1.1 + 0.5}em`
201
+
202
+ const toggle = document.createElement('button')
203
+ toggle.type = 'button'
204
+ toggle.className = 'link-tree-toggle'
205
+ if (node.has_children) {
206
+ toggle.innerHTML = CHEVRON_COLLAPSED
207
+ toggle.setAttribute('aria-label', this._text('expandText'))
208
+ toggle.addEventListener('mousedown', (e) => e.preventDefault())
209
+ toggle.addEventListener('click', (e) => {
210
+ e.preventDefault()
211
+ e.stopPropagation()
212
+ this._toggleNode(li)
213
+ })
214
+ } else {
215
+ toggle.className = 'link-tree-toggle link-tree-toggle-empty'
216
+ toggle.tabIndex = -1
217
+ toggle.setAttribute('aria-hidden', 'true')
218
+ }
219
+ row.appendChild(toggle)
220
+
221
+ const label = document.createElement('span')
222
+ label.className = 'link-tree-label'
223
+ label.textContent = node.description || ''
224
+ row.appendChild(label)
225
+
226
+ row.addEventListener('mouseenter', () => this._setActive(row))
227
+ row.addEventListener('mousedown', (e) => e.preventDefault())
228
+ row.addEventListener('click', () => this._activateRow(row))
229
+
230
+ li.appendChild(row)
231
+
232
+ if (node.has_children) {
233
+ const childrenUl = document.createElement('ul')
234
+ childrenUl.className = 'link-tree-children'
235
+ childrenUl.hidden = true
236
+ li.appendChild(childrenUl)
237
+ }
238
+
239
+ return li
240
+ }
241
+
242
+ _toggleNode(li) {
243
+ if (!li) return
244
+ if (li.classList.contains('expanded')) {
245
+ this._collapseNode(li)
246
+ } else {
247
+ this._expandNode(li)
248
+ }
249
+ }
250
+
251
+ _collapseNode(li) {
252
+ if (!li || !li.classList.contains('expanded')) return
253
+ li.classList.remove('expanded')
254
+ const childrenUl = li.querySelector(':scope > .link-tree-children')
255
+ if (childrenUl) childrenUl.hidden = true
256
+ const toggle = li.querySelector(':scope > .link-tree-row > .link-tree-toggle')
257
+ if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_COLLAPSED
258
+ }
259
+
260
+ // Returns a promise that resolves once the node is expanded (children loaded
261
+ // and visible). Used both by user interaction and breadcrumb navigation.
262
+ _expandNode(li) {
263
+ if (!li) return Promise.resolve()
264
+ const childrenUl = li.querySelector(':scope > .link-tree-children')
265
+ if (!childrenUl) return Promise.resolve() // leaf
266
+
267
+ const toggle = li.querySelector(':scope > .link-tree-row > .link-tree-toggle')
268
+ li.classList.add('expanded')
269
+ childrenUl.hidden = false
270
+ if (toggle && !toggle.classList.contains('link-tree-toggle-empty')) toggle.innerHTML = CHEVRON_EXPANDED
271
+
272
+ if (li.dataset.loaded === '1') return Promise.resolve()
273
+
274
+ const level = parseInt(li.dataset.level, 10) + 1
275
+ li.dataset.loaded = '1'
276
+ childrenUl.innerHTML = `<li class="link-tree-loading">${this._escape(this._text('loadingText'))}</li>`
77
277
 
78
- const items = Array.isArray(results)
79
- ? results.map((result) => ({ id: result.id, label: result.description }))
80
- : []
81
- this.setItems(items)
278
+ return creativesApi.browse(li.dataset.id)
279
+ .then((nodes) => {
280
+ childrenUl.innerHTML = ''
281
+ const list = Array.isArray(nodes) ? nodes : []
282
+ if (list.length === 0) {
283
+ childrenUl.innerHTML = `<li class="link-tree-empty">${this._escape(this._text('emptyText'))}</li>`
284
+ return
285
+ }
286
+ list.forEach((child) => childrenUl.appendChild(this._buildTreeItem(child, level)))
82
287
  })
83
288
  .catch(() => {
84
- if (token === this._searchToken) this.setItems([])
289
+ li.dataset.loaded = '0'
290
+ childrenUl.innerHTML = ''
291
+ })
292
+ }
293
+
294
+ // --- Search (flat + breadcrumb) mode -------------------------------------
295
+
296
+ _renderSearchResults(results) {
297
+ this.listTarget.innerHTML = ''
298
+ if (!results || results.length === 0) {
299
+ this._renderMessage(this._text('noResultsText'))
300
+ return
301
+ }
302
+
303
+ results.forEach((result) => {
304
+ const li = document.createElement('li')
305
+ li.className = 'link-result-item'
306
+ li.setAttribute('data-pick-row', '')
307
+ li.dataset.id = String(result.id)
308
+
309
+ const label = document.createElement('div')
310
+ label.className = 'link-result-label'
311
+ label.textContent = result.description || ''
312
+ li.appendChild(label)
313
+
314
+ if (Array.isArray(result.path) && result.path.length > 0) {
315
+ li.appendChild(this._buildBreadcrumb(result))
316
+ }
317
+
318
+ li.addEventListener('mouseenter', () => this._setActive(li))
319
+ li.addEventListener('mousedown', (e) => e.preventDefault())
320
+ li.addEventListener('click', () => this._activateRow(li))
321
+
322
+ this.listTarget.appendChild(li)
323
+ })
324
+ this._resetActive()
325
+ }
326
+
327
+ _buildBreadcrumb(result) {
328
+ const nav = document.createElement('div')
329
+ nav.className = 'link-result-path'
330
+
331
+ result.path.forEach((crumb, index) => {
332
+ if (index > 0) {
333
+ const sep = document.createElement('span')
334
+ sep.className = 'link-crumb-sep'
335
+ sep.textContent = '›'
336
+ nav.appendChild(sep)
337
+ }
338
+ // Ancestors the user cannot read are kept (to preserve depth) but
339
+ // masked and non-navigable — clicking them would reveal nothing.
340
+ if (crumb.restricted) {
341
+ const masked = document.createElement('span')
342
+ masked.className = 'link-crumb link-crumb-restricted'
343
+ masked.textContent = '…'
344
+ nav.appendChild(masked)
345
+ return
346
+ }
347
+
348
+ const btn = document.createElement('button')
349
+ btn.type = 'button'
350
+ btn.className = 'link-crumb'
351
+ btn.dataset.id = String(crumb.id)
352
+ btn.textContent = crumb.description || ''
353
+ btn.addEventListener('mousedown', (e) => e.preventDefault())
354
+ btn.addEventListener('click', (e) => {
355
+ e.preventDefault()
356
+ e.stopPropagation()
357
+ this._navigateToCrumb(result, index)
85
358
  })
359
+ nav.appendChild(btn)
360
+ })
361
+
362
+ return nav
363
+ }
364
+
365
+ // Clicking a breadcrumb segment switches to the tree, expands the path down
366
+ // to that ancestor, and highlights it — turning search into a jump-to-place.
367
+ _navigateToCrumb(result, crumbIndex) {
368
+ // When the hit lives under a linked shell nested in the user's own tree,
369
+ // the origin-space crumbs omit the local folders above the shell. The
370
+ // server-supplied reveal_path maps each origin ancestor id to the local
371
+ // path ([localFolder..., shellId]) that surfaces *that* ancestor's shell.
372
+ // Anchor at the entry at/above the clicked crumb (nearest first) so a
373
+ // higher ancestor crumb resolves through its own shell, not a deeper one;
374
+ // origins between the anchoring shell and the target render as the shell's
375
+ // descendants, while origins above the anchor are not in the user's tree.
376
+ const path = result.path
377
+ const revealMap =
378
+ result.reveal_path && typeof result.reveal_path === 'object' && !Array.isArray(result.reveal_path)
379
+ ? result.reveal_path
380
+ : null
381
+ let localPrefix = []
382
+ let anchorIndex = -1
383
+ if (revealMap) {
384
+ for (let i = crumbIndex; i >= 0; i--) {
385
+ const entry = revealMap[String(path[i].id)]
386
+ if (entry) {
387
+ localPrefix = entry.map(String)
388
+ anchorIndex = i
389
+ break
390
+ }
391
+ }
392
+ }
393
+ const originChain = path.slice(anchorIndex + 1, crumbIndex).map((p) => String(p.id))
394
+ const chain = localPrefix.concat(originChain)
395
+ const targetId = String(path[crumbIndex].id)
396
+
397
+ this.inputTarget.value = ''
398
+ this._searchToken++
399
+ this._mode = 'tree'
400
+
401
+ const reveal = () => this._expandChain(chain).then(() => this._highlight(targetId))
402
+
403
+ if (this._rootNodes) {
404
+ this._renderTree(this._rootNodes)
405
+ reveal()
406
+ } else {
407
+ creativesApi.browse(null)
408
+ .then((nodes) => {
409
+ this._rootNodes = Array.isArray(nodes) ? nodes : []
410
+ this._renderTree(this._rootNodes)
411
+ return reveal()
412
+ })
413
+ .catch(() => this._renderMessage(this._text('emptyText')))
414
+ }
415
+ }
416
+
417
+ _expandChain(ids) {
418
+ return ids.reduce((promise, id) => {
419
+ return promise.then(() => {
420
+ const li = this._findItem(id)
421
+ return li ? this._expandNode(li) : null
422
+ })
423
+ }, Promise.resolve())
424
+ }
425
+
426
+ _highlight(id) {
427
+ const li = this._findItem(id)
428
+ if (!li) return
429
+ const row = li.querySelector(':scope > .link-tree-row')
430
+ if (row) {
431
+ this._setActive(row)
432
+ row.scrollIntoView({ block: 'nearest' })
433
+ }
434
+ }
435
+
436
+ _findItem(id) {
437
+ // Match the rendered node id first; fall back to the effective origin id
438
+ // so a breadcrumb's root crumb (origin id) resolves to its linked shell.
439
+ return (
440
+ this.listTarget.querySelector(`.link-tree-item[data-id="${id}"]`) ||
441
+ this.listTarget.querySelector(`.link-tree-item[data-origin-id="${id}"]`)
442
+ )
443
+ }
444
+
445
+ // --- Selection & keyboard navigation -------------------------------------
446
+
447
+ _activateRow(row) {
448
+ if (!row || !row.hasAttribute('data-pick-row')) return
449
+ // For a linked-creative shell row, emit the effective origin id, not the
450
+ // shell id: consumers use the selected id as the new link's origin, and
451
+ // linking to the shell (rather than the real shared creative) would make
452
+ // PermissionChecker treat the shell as the permission base. Flat search
453
+ // rows already carry the origin creative's id, so they pass through.
454
+ const item = row.closest('.link-tree-item')
455
+ const id = Number((item && item.dataset.originId) || row.dataset.id)
456
+ const labelEl = row.querySelector('.link-tree-label, .link-result-label')
457
+ const label = labelEl ? labelEl.textContent : ''
458
+ this.select({ id, label })
86
459
  }
87
460
 
88
461
  // Override select to invoke callback
@@ -93,15 +466,64 @@ export default class extends CommonPopupController {
93
466
  this.close()
94
467
  }
95
468
 
96
- renderItem(item) {
97
- // Escape HTML to prevent XSS since CommonPopup uses innerHTML
98
- const text = item.label || ''
99
- return text
100
- .replace(/&/g, "&amp;")
101
- .replace(/</g, "&lt;")
102
- .replace(/>/g, "&gt;")
103
- .replace(/"/g, "&quot;")
104
- .replace(/'/g, "&#039;")
469
+ _visibleRows() {
470
+ return Array.from(this.listTarget.querySelectorAll('[data-pick-row]'))
471
+ .filter((el) => el.offsetParent !== null)
472
+ }
473
+
474
+ _resetActive() {
475
+ const rows = this._visibleRows()
476
+ this._setActive(rows[0] || null)
477
+ }
478
+
479
+ _setActive(row) {
480
+ if (this._activeEl === row) return
481
+ if (this._activeEl) this._activeEl.classList.remove('active')
482
+ this._activeEl = row
483
+ if (row) row.classList.add('active')
484
+ }
485
+
486
+ _moveActive(delta) {
487
+ const rows = this._visibleRows()
488
+ if (rows.length === 0) return
489
+ this._navigated = true
490
+ const current = rows.indexOf(this._activeEl)
491
+ let next = current + delta
492
+ if (next < 0) next = rows.length - 1
493
+ if (next >= rows.length) next = 0
494
+ const row = rows[next]
495
+ this._setActive(row)
496
+ row.scrollIntoView({ block: 'nearest' })
497
+ }
498
+
499
+ // --- Helpers -------------------------------------------------------------
500
+
501
+ _renderMessage(text) {
502
+ this.listTarget.innerHTML = ''
503
+ const li = document.createElement('li')
504
+ li.className = 'link-popup-message'
505
+ li.textContent = text
506
+ this.listTarget.appendChild(li)
507
+ this._activeEl = null
508
+ }
509
+
510
+ _text(key) {
511
+ const map = {
512
+ loadingText: this.element.dataset.linkCreativeLoadingText,
513
+ noResultsText: this.element.dataset.linkCreativeNoResultsText,
514
+ emptyText: this.element.dataset.linkCreativeEmptyText,
515
+ expandText: this.element.dataset.linkCreativeExpandText,
516
+ }
517
+ return map[key] || ''
518
+ }
519
+
520
+ _escape(text) {
521
+ return String(text || '')
522
+ .replace(/&/g, '&amp;')
523
+ .replace(/</g, '&lt;')
524
+ .replace(/>/g, '&gt;')
525
+ .replace(/"/g, '&quot;')
526
+ .replace(/'/g, '&#039;')
105
527
  }
106
528
 
107
529
  dispatchClose(reason) {
@@ -163,6 +163,12 @@ function applyRowProperties(row, node) {
163
163
  if (Object.prototype.hasOwnProperty.call(inlinePayload, 'origin_id')) {
164
164
  setDatasetValue(row, 'originId', inlinePayload.origin_id ?? '')
165
165
  }
166
+ if (Object.prototype.hasOwnProperty.call(inlinePayload, 'content_type')) {
167
+ setDatasetValue(row, 'contentType', inlinePayload.content_type ?? '')
168
+ }
169
+ if (Object.prototype.hasOwnProperty.call(inlinePayload, 'markdown_source')) {
170
+ setDatasetValue(row, 'markdownSource', inlinePayload.markdown_source ?? '')
171
+ }
166
172
 
167
173
  if (dirty && typeof row.requestUpdate === 'function') {
168
174
  // Before Lit re-renders, sync progressHtml from current DOM.
@@ -122,6 +122,33 @@ describe('ApiQueueManager', () => {
122
122
  expect(options.body.has('file')).toBe(true);
123
123
  });
124
124
 
125
+ test('should pass parsed JSON response data to onSuccess callback', async () => {
126
+ // Restore processQueue for this test
127
+ apiQueue.processQueue.mockRestore();
128
+
129
+ const callback = jest.fn();
130
+
131
+ // Mock successful response with JSON body containing markdown_source rewrite
132
+ const rewrittenSource = '![img](/rails/active_storage/blobs/abc/image.png)';
133
+ mockCsrfFetch.mockResolvedValue({
134
+ ok: true,
135
+ text: async () => JSON.stringify({ id: 42, markdown_source: rewrittenSource })
136
+ });
137
+
138
+ const item = {
139
+ path: '/creatives/42',
140
+ method: 'PATCH',
141
+ onSuccess: callback,
142
+ retries: 0
143
+ };
144
+
145
+ apiQueue.queue = [item];
146
+ await apiQueue.processQueue();
147
+
148
+ expect(callback).toHaveBeenCalledTimes(1);
149
+ expect(callback).toHaveBeenCalledWith({ id: 42, markdown_source: rewrittenSource });
150
+ });
151
+
125
152
  test('should dispatch event on permanent failure', async () => {
126
153
  // Restore processQueue for this test
127
154
  apiQueue.processQueue.mockRestore();
@@ -18,6 +18,18 @@ export function loadChildren(url) {
18
18
  return csrfFetch(url, { headers: JSON_HEADERS }).then((response) => response.json())
19
19
  }
20
20
 
21
+ // Browse the creative tree for the picker popup. Returns the lightweight
22
+ // simple payload ([{ id, description, progress, has_children }]) for the
23
+ // children of `parentId`, or the roots when `parentId` is null/undefined.
24
+ export function browse(parentId) {
25
+ const params = new URLSearchParams({ simple: 'true' })
26
+ if (parentId != null) params.set('id', parentId)
27
+
28
+ return csrfFetch(`/creatives.json?${params.toString()}`, {
29
+ headers: JSON_HEADERS,
30
+ }).then((response) => response.json())
31
+ }
32
+
21
33
  export function search(query, { simple = false } = {}) {
22
34
  const params = new URLSearchParams()
23
35
  if (query != null) params.set('search', query)
@@ -92,6 +104,7 @@ const creativesApi = {
92
104
  get,
93
105
  parentSuggestions,
94
106
  loadChildren,
107
+ browse,
95
108
  search,
96
109
  save,
97
110
  linkExisting,
@@ -174,11 +174,11 @@ class ApiQueueManager {
174
174
  // Merge new callback with existing callbacks
175
175
  let mergedCallback = null
176
176
  if (existingCallbacks.length > 0 || request.onSuccess) {
177
- mergedCallback = () => {
177
+ mergedCallback = (responseData) => {
178
178
  // Run all existing callbacks first
179
179
  existingCallbacks.forEach(cb => {
180
180
  try {
181
- cb()
181
+ cb(responseData)
182
182
  } catch (error) {
183
183
  console.error('Merged callback failed:', error)
184
184
  }
@@ -186,7 +186,7 @@ class ApiQueueManager {
186
186
  // Then run the new callback
187
187
  if (typeof request.onSuccess === 'function') {
188
188
  try {
189
- request.onSuccess()
189
+ request.onSuccess(responseData)
190
190
  } catch (error) {
191
191
  console.error('New callback failed:', error)
192
192
  }
@@ -229,8 +229,20 @@ class ApiQueueManager {
229
229
  while (this.queue.length > 0) {
230
230
  const item = this.queue[0]
231
231
 
232
+ let responseData = null
232
233
  try {
233
- await this.executeRequest(item)
234
+ const response = await this.executeRequest(item)
235
+ // Parse JSON body so callbacks can react to server-side rewrites
236
+ // (e.g. markdown_source data: URIs → blob paths). Best-effort: empty
237
+ // or non-JSON bodies leave responseData null.
238
+ if (response && typeof response.text === 'function') {
239
+ try {
240
+ const text = await response.text()
241
+ responseData = text ? JSON.parse(text) : null
242
+ } catch (_parseError) {
243
+ responseData = null
244
+ }
245
+ }
234
246
  // Success - handle cleanup actions
235
247
 
236
248
  // Dispatch event for attachment cleanup if needed
@@ -243,7 +255,7 @@ class ApiQueueManager {
243
255
  // Call onSuccess callback if provided (for non-serializable actions)
244
256
  if (typeof item.onSuccess === 'function') {
245
257
  try {
246
- item.onSuccess()
258
+ item.onSuccess(responseData)
247
259
  } catch (callbackError) {
248
260
  console.error('onSuccess callback failed:', callbackError)
249
261
  }