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
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
8
+ renderCreativeTree: jest.fn(),
9
+ dispatchCreativeTreeUpdated: jest.fn(),
10
+ applyRowProperties: jest.fn(),
11
+ }))
12
+
13
+ jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
14
+ parseEmojis: jest.fn(() => ['✨']),
15
+ }))
16
+
17
+ const { Application } = await import('@hotwired/stimulus')
18
+ const TreeController = (await import('../tree_controller')).default
19
+
20
+ const TRANSIENT_RETRY_DELAYS = [200, 600]
21
+
22
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
23
+
24
+ const collectRetryDelays = (spy) =>
25
+ spy.mock.calls
26
+ .map((args) => args[1])
27
+ .filter((delay) => TRANSIENT_RETRY_DELAYS.includes(delay))
28
+
29
+ const installController = () => {
30
+ const container = document.createElement('div')
31
+ container.setAttribute('data-controller', 'creatives--tree')
32
+ container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
33
+ document.body.appendChild(container)
34
+
35
+ const application = Application.start()
36
+ application.register('creatives--tree', TreeController)
37
+
38
+ return { container, application }
39
+ }
40
+
41
+ describe('CreativesTreeController retry on transient network errors', () => {
42
+ let originalFetch
43
+ let setTimeoutSpy
44
+
45
+ beforeEach(() => {
46
+ originalFetch = global.fetch
47
+ setTimeoutSpy = jest.spyOn(global, 'setTimeout')
48
+ })
49
+
50
+ afterEach(() => {
51
+ setTimeoutSpy.mockRestore()
52
+ global.fetch = originalFetch
53
+ document.body.innerHTML = ''
54
+ jest.restoreAllMocks()
55
+ })
56
+
57
+ test('schedules 200ms then 600ms backoff retry on TypeError "Failed to fetch"', async () => {
58
+ global.fetch = jest
59
+ .fn()
60
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
61
+ .mockRejectedValueOnce(new TypeError('Failed to fetch'))
62
+ .mockResolvedValueOnce({
63
+ ok: true,
64
+ json: async () => ({ creatives: [] }),
65
+ })
66
+
67
+ const { application } = installController()
68
+ // Allow time for the 200ms + 600ms retries to fire
69
+ await new Promise((resolve) => setTimeout(resolve, 1000))
70
+
71
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
72
+
73
+ application.stop()
74
+ })
75
+
76
+ test('does NOT schedule retry on HTTP error responses', async () => {
77
+ jest.spyOn(console, 'error').mockImplementation(() => {})
78
+ global.fetch = jest.fn().mockResolvedValue({
79
+ ok: false,
80
+ status: 500,
81
+ json: async () => ({}),
82
+ })
83
+
84
+ const { application } = installController()
85
+ await flush()
86
+ await flush()
87
+ await flush()
88
+
89
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
90
+
91
+ application.stop()
92
+ })
93
+
94
+ test('does NOT schedule retry on AbortError', async () => {
95
+ const abortErr = new Error('aborted')
96
+ abortErr.name = 'AbortError'
97
+ global.fetch = jest.fn().mockRejectedValue(abortErr)
98
+
99
+ const { application } = installController()
100
+ await flush()
101
+ await flush()
102
+
103
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
104
+
105
+ application.stop()
106
+ })
107
+
108
+ test('gives up after exactly 2 retries on persistent transient errors', async () => {
109
+ jest.spyOn(console, 'error').mockImplementation(() => {})
110
+ global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
111
+
112
+ const { application } = installController()
113
+ // Allow time for both retries to complete (200 + 600 = 800ms)
114
+ await new Promise((resolve) => setTimeout(resolve, 1500))
115
+
116
+ expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
117
+
118
+ application.stop()
119
+ })
120
+ })
@@ -24,20 +24,41 @@ export default class extends Controller {
24
24
  }
25
25
  }
26
26
 
27
- document.addEventListener('creative-editing:start', this.handleEditStart)
28
- document.addEventListener('creative-editing:stop', this.handleEditStop)
27
+ // Defer ActionCable subscribe until tree finishes loading. Opening the
28
+ // WebSocket while the tree fetch is in flight triggers ERR_NETWORK_CHANGED
29
+ // in Chromium because the browser tears down the in-flight HTTP connection.
30
+ // Exception: Turbo cache restores leave data-loaded="true" so tree_controller
31
+ // skips load() and never dispatches creative-tree:updated — subscribe now.
32
+ const subscribeForRoot = () => {
33
+ if (this.rootIdValue > 0) {
34
+ this.subscribe()
35
+ } else {
36
+ this.inferAndSubscribe()
37
+ }
38
+ }
29
39
 
30
- if (this.rootIdValue > 0) {
31
- this.subscribe()
40
+ if (this.element.dataset.loaded === 'true') {
41
+ subscribeForRoot()
32
42
  } else {
33
- // Top-level /creatives page — try to infer root from tree rows
34
- this.inferAndSubscribe()
43
+ this._handleTreeUpdated = () => {
44
+ this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
45
+ this._handleTreeUpdated = null
46
+ subscribeForRoot()
47
+ }
48
+ this.element.addEventListener('creative-tree:updated', this._handleTreeUpdated)
35
49
  }
50
+
51
+ document.addEventListener('creative-editing:start', this.handleEditStart)
52
+ document.addEventListener('creative-editing:stop', this.handleEditStop)
36
53
  }
37
54
 
38
55
  disconnect() {
39
56
  document.removeEventListener('creative-editing:start', this.handleEditStart)
40
57
  document.removeEventListener('creative-editing:stop', this.handleEditStop)
58
+ if (this._handleTreeUpdated) {
59
+ this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
60
+ this._handleTreeUpdated = null
61
+ }
41
62
 
42
63
  if (this.subscription) {
43
64
  this.subscription.cleanup()
@@ -50,9 +71,9 @@ export default class extends Controller {
50
71
  this.subscription.cleanup()
51
72
  this.subscription = null
52
73
  }
53
- if (this.rootIdValue > 0) {
54
- this.subscribe()
55
- }
74
+ // Skip re-subscribing here — connect() defers the first subscribe until
75
+ // creative-tree:updated fires so the WebSocket handshake doesn't race
76
+ // the tree's HTTP fetch.
56
77
  }
57
78
 
58
79
  inferAndSubscribe() {
@@ -2,6 +2,8 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
3
3
  import { parseEmojis } from '../../utils/emoji_parser'
4
4
 
5
+ const TREE_RETRY_DELAYS_MS = [200, 600]
6
+
5
7
  export default class extends Controller {
6
8
  static values = {
7
9
  url: String,
@@ -49,6 +51,10 @@ export default class extends Controller {
49
51
  this.abortController.abort()
50
52
  this.abortController = null
51
53
  }
54
+ if (this._retryTimer) {
55
+ clearTimeout(this._retryTimer)
56
+ this._retryTimer = null
57
+ }
52
58
  window.removeEventListener('resize', this.handleResize)
53
59
  this.element.removeEventListener('creative-tree:updated', this.handleTreeUpdated)
54
60
  document.removeEventListener('creative-editing:start', this._handleEditStart)
@@ -138,8 +144,12 @@ export default class extends Controller {
138
144
  this.abortController.abort()
139
145
  }
140
146
  this.abortController = new AbortController()
147
+ this._retryCount = 0
141
148
  this.showLoadingIndicator()
149
+ this._fetchTree()
150
+ }
142
151
 
152
+ _fetchTree() {
143
153
  fetch(this.urlValue, {
144
154
  headers: { Accept: 'application/json' },
145
155
  signal: this.abortController.signal,
@@ -154,12 +164,25 @@ export default class extends Controller {
154
164
  })
155
165
  .catch((error) => {
156
166
  if (error.name === 'AbortError') return
167
+ // Transient network failures (ERR_NETWORK_CHANGED, offline blips, VPN
168
+ // toggles) surface as TypeError "Failed to fetch". Retry briefly so a
169
+ // momentary network event doesn't leave the user with an empty tree.
170
+ if (this._isTransientNetworkError(error) && this._retryCount < TREE_RETRY_DELAYS_MS.length) {
171
+ const delay = TREE_RETRY_DELAYS_MS[this._retryCount]
172
+ this._retryCount += 1
173
+ this._retryTimer = setTimeout(() => this._fetchTree(), delay)
174
+ return
175
+ }
157
176
  console.error(error)
158
177
  this.hideLoadingIndicator()
159
178
  this.showEmptyState()
160
179
  })
161
180
  }
162
181
 
182
+ _isTransientNetworkError(error) {
183
+ return error instanceof TypeError && /fetch|network/i.test(error.message || '')
184
+ }
185
+
163
186
  renderData(data) {
164
187
  const nodes = Array.isArray(data?.creatives) ? data.creatives : []
165
188
 
@@ -33,6 +33,7 @@ import CommentBadgeController from "./comment_badge_controller"
33
33
  import ShareModalController from "./share_modal_controller"
34
34
  import ImageLightboxController from "./image_lightbox_controller"
35
35
  import SearchPopupController from "./search_popup_controller"
36
+ import LandingVideoController from "./landing_video_controller"
36
37
 
37
38
  // Export all controllers
38
39
  export {
@@ -67,7 +68,8 @@ export {
67
68
  ShareModalController,
68
69
  ImageLightboxController,
69
70
  SearchPopupController,
70
- CommentBadgeController
71
+ CommentBadgeController,
72
+ LandingVideoController
71
73
  }
72
74
 
73
75
  // Registration function for use with a Stimulus application
@@ -105,4 +107,5 @@ export function registerControllers(application) {
105
107
  application.register("image-lightbox", ImageLightboxController)
106
108
  application.register("search-popup", SearchPopupController)
107
109
  application.register("comment-badge", CommentBadgeController)
110
+ application.register("landing-video", LandingVideoController)
108
111
  }
@@ -0,0 +1,53 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["video", "progressBar", "progressFill", "toggle", "icon"]
5
+
6
+ connect() {
7
+ this._raf = null
8
+ this._updateProgress = this._updateProgress.bind(this)
9
+ this.videoTarget.addEventListener("play", () => this._startProgress())
10
+ this.videoTarget.addEventListener("pause", () => this._stopProgress())
11
+ this.videoTarget.addEventListener("ended", () => this._stopProgress())
12
+ if (!this.videoTarget.paused) this._startProgress()
13
+ }
14
+
15
+ disconnect() {
16
+ this._stopProgress()
17
+ }
18
+
19
+ togglePlay() {
20
+ if (this.videoTarget.paused) {
21
+ this.videoTarget.play()
22
+ this.iconTarget.textContent = "❚❚"
23
+ } else {
24
+ this.videoTarget.pause()
25
+ this.iconTarget.textContent = "▶"
26
+ }
27
+ }
28
+
29
+ _startProgress() {
30
+ this._stopProgress()
31
+ this._tick()
32
+ }
33
+
34
+ _stopProgress() {
35
+ if (this._raf) {
36
+ cancelAnimationFrame(this._raf)
37
+ this._raf = null
38
+ }
39
+ }
40
+
41
+ _tick() {
42
+ this._updateProgress()
43
+ this._raf = requestAnimationFrame(() => this._tick())
44
+ }
45
+
46
+ _updateProgress() {
47
+ const v = this.videoTarget
48
+ if (v.duration) {
49
+ const pct = (v.currentTime / v.duration) * 100
50
+ this.progressFillTarget.style.width = `${pct}%`
51
+ }
52
+ }
53
+ }
@@ -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();
@@ -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
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import CommandArgsForm from '../command_args_form'
5
+
6
+ const baseCommand = (overrides = {}) => ({
7
+ name: 'foo_tool',
8
+ label: '/foo_tool',
9
+ input_schema: [
10
+ { name: 'creative_id', type: 'string', required: false },
11
+ { name: 'topic_id', type: 'string', required: false },
12
+ { name: 'note', type: 'string', required: false }
13
+ ],
14
+ ...overrides
15
+ })
16
+
17
+ const fieldFor = (paramName) =>
18
+ document.querySelector(`[data-param-name="${paramName}"]`)
19
+
20
+ describe('CommandArgsForm context auto-fill', () => {
21
+ let container
22
+
23
+ beforeEach(() => {
24
+ container = document.createElement('div')
25
+ document.body.appendChild(container)
26
+ })
27
+
28
+ afterEach(() => {
29
+ document.body.innerHTML = ''
30
+ })
31
+
32
+ test('fills creative_id and topic_id from contextValuesFn', () => {
33
+ const form = new CommandArgsForm({
34
+ container,
35
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
36
+ })
37
+
38
+ form.show(baseCommand())
39
+
40
+ expect(fieldFor('creative_id').value).toBe('12508')
41
+ expect(fieldFor('topic_id').value).toBe('8455')
42
+ expect(fieldFor('note').value).toBe('')
43
+ })
44
+
45
+ test('explicit default_value beats context auto-fill', () => {
46
+ const form = new CommandArgsForm({
47
+ container,
48
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455' })
49
+ })
50
+
51
+ form.show(baseCommand({
52
+ input_schema: [
53
+ { name: 'creative_id', type: 'string', required: false, default_value: '999' },
54
+ { name: 'topic_id', type: 'string', required: false }
55
+ ]
56
+ }))
57
+
58
+ expect(fieldFor('creative_id').value).toBe('999')
59
+ expect(fieldFor('topic_id').value).toBe('8455')
60
+ })
61
+
62
+ test('does not fill unrelated params even if names overlap', () => {
63
+ const form = new CommandArgsForm({
64
+ container,
65
+ contextValuesFn: () => ({ creative_id: '12508', topic_id: '8455', note: 'ctx' })
66
+ })
67
+
68
+ form.show(baseCommand())
69
+
70
+ expect(fieldFor('note').value).toBe('')
71
+ })
72
+
73
+ test('missing context values leave fields empty', () => {
74
+ const form = new CommandArgsForm({
75
+ container,
76
+ contextValuesFn: () => ({ creative_id: null, topic_id: '' })
77
+ })
78
+
79
+ form.show(baseCommand())
80
+
81
+ expect(fieldFor('creative_id').value).toBe('')
82
+ expect(fieldFor('topic_id').value).toBe('')
83
+ })
84
+
85
+ test('contextValuesFn throwing does not break form', () => {
86
+ const form = new CommandArgsForm({
87
+ container,
88
+ contextValuesFn: () => { throw new Error('boom') }
89
+ })
90
+
91
+ expect(() => form.show(baseCommand())).not.toThrow()
92
+ expect(fieldFor('creative_id').value).toBe('')
93
+ })
94
+
95
+ test('no contextValuesFn provided behaves like before', () => {
96
+ const form = new CommandArgsForm({ container })
97
+
98
+ form.show(baseCommand())
99
+
100
+ expect(fieldFor('creative_id').value).toBe('')
101
+ expect(fieldFor('topic_id').value).toBe('')
102
+ })
103
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { isHtmlEmpty } from '../html_content_empty';
5
+
6
+ describe('isHtmlEmpty', () => {
7
+ test('returns true for null/empty string', () => {
8
+ expect(isHtmlEmpty('')).toBe(true);
9
+ expect(isHtmlEmpty(null)).toBe(true);
10
+ expect(isHtmlEmpty(undefined)).toBe(true);
11
+ });
12
+
13
+ test('returns true for whitespace-only HTML', () => {
14
+ expect(isHtmlEmpty('<p> </p>')).toBe(true);
15
+ expect(isHtmlEmpty('<div><br></div>')).toBe(true);
16
+ });
17
+
18
+ test('returns false when there is text content', () => {
19
+ expect(isHtmlEmpty('<p>hello</p>')).toBe(false);
20
+ });
21
+
22
+ test('returns false for image-only HTML', () => {
23
+ expect(isHtmlEmpty('<p><img src="/blob/abc.png"></p>')).toBe(false);
24
+ });
25
+
26
+ test('returns false for action-text-attachment-only HTML', () => {
27
+ expect(isHtmlEmpty(
28
+ '<action-text-attachment sgid="abc" url="/blob" filename="file.png"></action-text-attachment>'
29
+ )).toBe(false);
30
+ });
31
+
32
+ test('returns false for figure.attachment-only HTML (trix-style)', () => {
33
+ expect(isHtmlEmpty(
34
+ '<figure class="attachment attachment--file" data-trix-attachment=\'{"sgid":"abc"}\'></figure>'
35
+ )).toBe(false);
36
+ });
37
+
38
+ test('returns false for [data-trix-attachment]-only HTML', () => {
39
+ expect(isHtmlEmpty('<div data-trix-attachment=\'{"sgid":"x"}\'></div>')).toBe(false);
40
+ });
41
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { reconcileMarkdownSource } from '../markdown_source_reconcile';
5
+
6
+ const DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
7
+ const BLOB_PATH = '/rails/active_storage/blobs/redirect/abc123def/image.png';
8
+ const DATA_URI_2 = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
9
+ const BLOB_PATH_2 = '/rails/active_storage/blobs/redirect/xyz789/two.gif';
10
+
11
+ describe('reconcileMarkdownSource', () => {
12
+ test('returns rewritten value when current still equals saved', () => {
13
+ const saved = `Hello ![pic](${DATA_URI}) world`;
14
+ const rewritten = `Hello ![pic](${BLOB_PATH}) world`;
15
+ expect(reconcileMarkdownSource(saved, rewritten, saved)).toBe(rewritten);
16
+ });
17
+
18
+ test('returns null when no rewrites happened', () => {
19
+ expect(reconcileMarkdownSource('a', 'a', 'a')).toBe(null);
20
+ });
21
+
22
+ test('returns null when saved has no data URI and current diverged from saved', () => {
23
+ expect(reconcileMarkdownSource('saved', 'rewritten', 'user typed something else')).toBe(null);
24
+ });
25
+
26
+ test('merges substitutions when user typed AFTER the data URI mid-save', () => {
27
+ const saved = `![pic](${DATA_URI})`;
28
+ const rewritten = `![pic](${BLOB_PATH})`;
29
+ const current = `![pic](${DATA_URI})\n\nNew paragraph user typed during save.`;
30
+ const expected = `![pic](${BLOB_PATH})\n\nNew paragraph user typed during save.`;
31
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
32
+ });
33
+
34
+ test('merges substitutions when user typed BEFORE the data URI mid-save', () => {
35
+ const saved = `start ![pic](${DATA_URI}) end`;
36
+ const rewritten = `start ![pic](${BLOB_PATH}) end`;
37
+ const current = `inserted at top\n\nstart ![pic](${DATA_URI}) end`;
38
+ const expected = `inserted at top\n\nstart ![pic](${BLOB_PATH}) end`;
39
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
40
+ });
41
+
42
+ test('handles multiple data URIs in a single save', () => {
43
+ const saved = `A ![1](${DATA_URI}) B ![2](${DATA_URI_2}) C`;
44
+ const rewritten = `A ![1](${BLOB_PATH}) B ![2](${BLOB_PATH_2}) C`;
45
+ const current = `A ![1](${DATA_URI}) B ![2](${DATA_URI_2}) C\n\nextra text`;
46
+ const expected = `A ![1](${BLOB_PATH}) B ![2](${BLOB_PATH_2}) C\n\nextra text`;
47
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
48
+ });
49
+
50
+ test('handles reference-style data URI definitions', () => {
51
+ const saved = `![pic][p]\n\n[p]: ${DATA_URI}`;
52
+ const rewritten = `![pic][p]\n\n[p]: ${BLOB_PATH}`;
53
+ const current = `![pic][p]\n\nuser typed more\n\n[p]: ${DATA_URI}`;
54
+ const expected = `![pic][p]\n\nuser typed more\n\n[p]: ${BLOB_PATH}`;
55
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(expected);
56
+ });
57
+
58
+ test('returns null when user removed the data URI before response', () => {
59
+ const saved = `![pic](${DATA_URI})`;
60
+ const rewritten = `![pic](${BLOB_PATH})`;
61
+ const current = 'user wiped everything';
62
+ expect(reconcileMarkdownSource(saved, rewritten, current)).toBe(null);
63
+ });
64
+
65
+ test('returns null on non-string inputs', () => {
66
+ expect(reconcileMarkdownSource(null, 'a', 'a')).toBe(null);
67
+ expect(reconcileMarkdownSource('a', null, 'a')).toBe(null);
68
+ expect(reconcileMarkdownSource('a', 'a', null)).toBe(null);
69
+ });
70
+ });