collavre 0.22.0 → 0.23.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 (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  4. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  5. data/app/assets/stylesheets/collavre/comments_popup.css +118 -61
  6. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  7. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  8. data/app/assets/stylesheets/collavre/tables.css +91 -0
  9. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  10. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  11. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  12. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  13. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  14. data/app/controllers/collavre/creatives_controller.rb +16 -5
  15. data/app/controllers/collavre/tasks_controller.rb +13 -4
  16. data/app/controllers/collavre/topics_controller.rb +49 -1
  17. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  18. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  19. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  20. data/app/helpers/collavre/application_helper.rb +1 -0
  21. data/app/javascript/collavre.js +2 -0
  22. data/app/javascript/components/ImageResizer.jsx +9 -3
  23. data/app/javascript/components/InlineLexicalEditor.jsx +155 -38
  24. data/app/javascript/components/creative_tree_row.js +20 -3
  25. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  26. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  27. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  28. data/app/javascript/controllers/comment_controller.js +5 -4
  29. data/app/javascript/controllers/comment_version_controller.js +2 -1
  30. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  31. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  33. data/app/javascript/controllers/comments/form_controller.js +21 -5
  34. data/app/javascript/controllers/comments/list_controller.js +18 -17
  35. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  36. data/app/javascript/controllers/comments/topics_controller.js +14 -8
  37. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  38. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  39. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  40. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  41. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  42. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  43. data/app/javascript/controllers/index.js +4 -1
  44. data/app/javascript/controllers/share_modal_controller.js +4 -3
  45. data/app/javascript/controllers/topic_search_controller.js +2 -1
  46. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  47. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  48. data/app/javascript/creatives/tree_renderer.js +11 -0
  49. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  50. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  51. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  52. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  53. data/app/javascript/lib/api/api_error.js +108 -0
  54. data/app/javascript/lib/api/queue_manager.js +38 -4
  55. data/app/javascript/lib/common_popup.js +18 -5
  56. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  57. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  58. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  59. data/app/javascript/lib/editor/code_languages.js +173 -0
  60. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  61. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  62. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  63. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  64. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  65. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  66. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  67. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  68. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  69. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  70. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  71. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  72. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  73. data/app/javascript/lib/turbo_confirm.js +46 -0
  74. data/app/javascript/lib/typo_correction.js +146 -0
  75. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  76. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  77. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  78. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  79. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  80. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  81. data/app/javascript/lib/utils/dialog.js +300 -0
  82. data/app/javascript/lib/utils/markdown.js +154 -67
  83. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  84. data/app/javascript/lib/utils/table_download.js +15 -0
  85. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  86. data/app/javascript/modules/creative_row_editor.js +110 -70
  87. data/app/javascript/modules/export_to_markdown.js +2 -1
  88. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  89. data/app/javascript/modules/slide_view.js +11 -2
  90. data/app/javascript/modules/typo_corrector.js +534 -0
  91. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  92. data/app/jobs/collavre/compress_job.rb +6 -2
  93. data/app/models/collavre/comment/broadcastable.rb +46 -7
  94. data/app/models/collavre/comment/notifiable.rb +14 -4
  95. data/app/models/collavre/comment.rb +79 -31
  96. data/app/models/collavre/creative/describable.rb +89 -10
  97. data/app/models/collavre/task.rb +15 -0
  98. data/app/models/collavre/user.rb +57 -1
  99. data/app/services/collavre/ai_client.rb +28 -10
  100. data/app/services/collavre/auto_theme_generator.rb +1 -1
  101. data/app/services/collavre/creatives/index_query.rb +85 -16
  102. data/app/services/collavre/creatives/tree_builder.rb +2 -1
  103. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  104. data/app/services/collavre/inbox_reply_service.rb +5 -0
  105. data/app/services/collavre/markdown_converter.rb +13 -3
  106. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  107. data/app/services/collavre/orchestration/agent_orchestrator.rb +33 -7
  108. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  109. data/app/services/collavre/orchestration/matcher.rb +79 -4
  110. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  111. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  112. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  113. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  114. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  115. data/app/services/collavre/typo_corrector.rb +188 -0
  116. data/app/views/collavre/comments/_comment.html.erb +5 -0
  117. data/app/views/collavre/comments/_comments_popup.html.erb +14 -1
  118. data/app/views/collavre/creatives/_inline_edit_form.html.erb +1 -0
  119. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  120. data/app/views/collavre/creatives/index.html.erb +14 -1
  121. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  122. data/app/views/collavre/users/show.html.erb +3 -0
  123. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  124. data/app/views/layouts/collavre/slide.html.erb +1 -0
  125. data/config/locales/comments.en.yml +15 -0
  126. data/config/locales/comments.ko.yml +15 -0
  127. data/config/locales/integrations.en.yml +1 -1
  128. data/config/locales/integrations.ko.yml +1 -1
  129. data/config/locales/mobile.en.yml +16 -0
  130. data/config/locales/mobile.ko.yml +16 -0
  131. data/config/locales/orchestration.en.yml +1 -0
  132. data/config/locales/orchestration.ko.yml +1 -0
  133. data/config/locales/users.en.yml +15 -0
  134. data/config/locales/users.ko.yml +15 -0
  135. data/config/routes.rb +13 -0
  136. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  137. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  138. data/db/seeds.rb +51 -0
  139. data/lib/collavre/version.rb +1 -1
  140. data/lib/generators/collavre/install/install_generator.rb +1 -0
  141. metadata +55 -2
  142. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -14,7 +14,6 @@ describe('CommentsPresenceController', () => {
14
14
  beforeEach(async () => {
15
15
  document.body.dataset.currentUserId = '7'
16
16
  global.fetch = jest.fn()
17
- global.alert = jest.fn()
18
17
 
19
18
  container = document.createElement('div')
20
19
  container.innerHTML = `
@@ -91,7 +90,9 @@ describe('CommentsPresenceController', () => {
91
90
 
92
91
  await new Promise((resolve) => setTimeout(resolve, 0))
93
92
 
94
- expect(global.alert).toHaveBeenCalledWith('No permission')
93
+ // alertDialog renders an in-app modal (replacing native alert for the
94
+ // Tauri webview); assert the message surfaced there instead.
95
+ expect(document.querySelector('.confirm-dialog-message')?.textContent).toBe('No permission')
95
96
  expect(close).toHaveBeenCalled()
96
97
  })
97
98
 
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ // deleteTopic awaits confirmDialog. The native click dispatch completes during
8
+ // that await, which resets event.currentTarget to null (DOM spec). Mock the
9
+ // dialog so the test controls when it resolves and can null currentTarget in
10
+ // between — reproducing the regression where the topic id was read after await.
11
+ jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
12
+ confirmDialog: jest.fn(),
13
+ alertDialog: jest.fn(),
14
+ }))
15
+
16
+ const { Application } = await import('@hotwired/stimulus')
17
+ const TopicsController = (await import('../topics_controller')).default
18
+ const { confirmDialog } = await import('../../../lib/utils/dialog')
19
+
20
+ describe('TopicsController#deleteTopic', () => {
21
+ let application
22
+ let container
23
+ let controller
24
+
25
+ beforeEach(() => {
26
+ container = document.createElement('div')
27
+ container.innerHTML = `
28
+ <div id="topics" data-controller="comments--topics">
29
+ <div data-comments--topics-target="list"></div>
30
+ </div>
31
+ `
32
+ document.body.appendChild(container)
33
+
34
+ application = Application.start()
35
+ application.register('comments--topics', TopicsController)
36
+
37
+ const meta = document.createElement('meta')
38
+ meta.name = 'csrf-token'
39
+ meta.content = 'test-csrf'
40
+ document.head.appendChild(meta)
41
+
42
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
43
+ const element = document.getElementById('topics')
44
+ controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
45
+ controller.creativeIdValue = '42'
46
+ controller.loadTopics = jest.fn()
47
+ })
48
+ })
49
+
50
+ afterEach(() => {
51
+ document.body.innerHTML = ''
52
+ document.head.innerHTML = ''
53
+ application.stop()
54
+ jest.clearAllMocks()
55
+ })
56
+
57
+ test('issues DELETE with the topic id even when currentTarget is nulled after the await', async () => {
58
+ const fetchMock = jest.fn().mockResolvedValue({ ok: true })
59
+ global.fetch = fetchMock
60
+
61
+ // Simulate the event whose currentTarget is reset to null once the click
62
+ // dispatch finishes (which happens while confirmDialog is awaited).
63
+ const button = document.createElement('button')
64
+ button.dataset.id = '99'
65
+ const event = { stopPropagation: jest.fn(), currentTarget: button }
66
+
67
+ confirmDialog.mockImplementation(() => {
68
+ event.currentTarget = null // dispatch completed -> currentTarget reset
69
+ return Promise.resolve(true)
70
+ })
71
+
72
+ await controller.deleteTopic(event)
73
+
74
+ expect(fetchMock).toHaveBeenCalledWith(
75
+ '/creatives/42/topics/99',
76
+ expect.objectContaining({ method: 'DELETE' })
77
+ )
78
+ })
79
+
80
+ test('does not issue DELETE when the user cancels the confirm dialog', async () => {
81
+ const fetchMock = jest.fn()
82
+ global.fetch = fetchMock
83
+
84
+ const button = document.createElement('button')
85
+ button.dataset.id = '99'
86
+ const event = { stopPropagation: jest.fn(), currentTarget: button }
87
+
88
+ confirmDialog.mockResolvedValue(false)
89
+
90
+ await controller.deleteTopic(event)
91
+
92
+ expect(fetchMock).not.toHaveBeenCalled()
93
+ })
94
+ })
@@ -3,6 +3,16 @@ import { renderMarkdownInContainer } from '../../lib/utils/markdown'
3
3
  import { wrapHtmlInCodeBlocks } from '../../lib/html_code_block_wrapper'
4
4
  import { refreshCsrfToken } from '../../lib/api/csrf_fetch'
5
5
  import ReviewQuotesStore from './review_quotes_store'
6
+ import { alertDialog } from '../../lib/utils/dialog'
7
+
8
+ // In-flight comment sends, keyed by creative id. This lives at module scope —
9
+ // not on the controller instance — so the duplicate-submit guard survives a
10
+ // Stimulus reconnect (Turbo morph / re-render) mid-send. The instance-only
11
+ // `this.sending` flag is reset by connect() and cannot be relied on alone:
12
+ // a reconnect while a slow request is in flight would re-enable sending and let
13
+ // an impatient second Enter submit the same comment twice.
14
+ const inFlightSends = new Set()
15
+ const sendKeyFor = (creativeId) => `creative:${creativeId}`
6
16
 
7
17
  export default class extends Controller {
8
18
  static targets = [
@@ -255,7 +265,9 @@ export default class extends Controller {
255
265
  const hasText = this.textareaTarget.value.trim().length > 0
256
266
  const hasQuotes = !store.isEmpty
257
267
  const hasImages = this.currentImageFiles().length > 0
258
- if (this.sending || (!hasText && !hasQuotes && !hasImages) || !this.creativeId) return
268
+ const sendKey = sendKeyFor(this.creativeId)
269
+ if (this.sending || inFlightSends.has(sendKey) || (!hasText && !hasQuotes && !hasImages) || !this.creativeId) return
270
+ inFlightSends.add(sendKey)
259
271
  this.sending = true
260
272
  this.setSendingState(true)
261
273
  this.presenceController?.stoppedTyping()
@@ -358,9 +370,10 @@ export default class extends Controller {
358
370
  this._renderReviewQuoteChips()
359
371
  this._updateSubmitButton()
360
372
  }
361
- alert(error?.message || 'Failed to submit comment')
373
+ alertDialog(error?.message || 'Failed to submit comment')
362
374
  })
363
375
  .finally(() => {
376
+ inFlightSends.delete(sendKey)
364
377
  this._hasRetried = false
365
378
  this.setSendingState(false)
366
379
  })
@@ -403,7 +416,7 @@ export default class extends Controller {
403
416
 
404
417
  const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition
405
418
  if (!SpeechRecognition) {
406
- alert(this.element.dataset.speechUnavailableText || 'Speech recognition not supported')
419
+ alertDialog(this.element.dataset.speechUnavailableText || 'Speech recognition not supported')
407
420
  return false
408
421
  }
409
422
 
@@ -743,7 +756,9 @@ export default class extends Controller {
743
756
 
744
757
  // Send a single question quote immediately as a standalone comment.
745
758
  _sendQuestionQuote(quote) {
746
- if (this.sending || !this.creativeId) return
759
+ const sendKey = sendKeyFor(this.creativeId)
760
+ if (this.sending || inFlightSends.has(sendKey) || !this.creativeId) return
761
+ inFlightSends.add(sendKey)
747
762
 
748
763
  const store = this._reviewStore
749
764
  const content = store.buildQuestionContent(quote)
@@ -809,9 +824,10 @@ export default class extends Controller {
809
824
  }
810
825
  })
811
826
  .catch((error) => {
812
- alert(error?.message || 'Failed to send question')
827
+ alertDialog(error?.message || 'Failed to send question')
813
828
  })
814
829
  .finally(() => {
830
+ inFlightSends.delete(sendKey)
815
831
  this._hasRetried = false
816
832
  this.sending = false
817
833
  })
@@ -5,6 +5,7 @@ import { renderMarkdownInContainer } from '../../lib/utils/markdown'
5
5
  import creativesApi from '../../lib/api/creatives'
6
6
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
7
7
  import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
8
+ import { alertDialog, confirmDialog } from '../../lib/utils/dialog'
8
9
  // CommonPopup is now used via TopicSearchController (Stimulus)
9
10
 
10
11
  export default class extends Controller {
@@ -595,7 +596,7 @@ export default class extends Controller {
595
596
  async deleteSelectedComments() {
596
597
  if (this.selection.size === 0) return
597
598
  const confirmText = this.element.dataset.batchDeleteConfirmText || 'Are you sure you want to delete the selected messages?'
598
- if (!confirm(confirmText)) return
599
+ if (!(await confirmDialog(confirmText, { danger: true }))) return
599
600
 
600
601
  const commentIds = Array.from(this.selection)
601
602
  try {
@@ -615,18 +616,18 @@ export default class extends Controller {
615
616
  this.clearSelection()
616
617
  } else {
617
618
  const data = await response.json().catch(() => ({}))
618
- alert(data.error || 'Failed to delete comments')
619
+ alertDialog(data.error || 'Failed to delete comments')
619
620
  }
620
621
  } catch (error) {
621
622
  console.error('Error deleting comments:', error)
622
- alert('Failed to delete comments')
623
+ alertDialog('Failed to delete comments')
623
624
  }
624
625
  }
625
626
 
626
627
  async mergeSelectedComments() {
627
628
  if (this.selection.size < 2) return
628
629
  const confirmText = this.element.dataset.mergeConfirmText || 'Merge the selected messages into one?'
629
- if (!confirm(confirmText)) return
630
+ if (!(await confirmDialog(confirmText, { danger: true }))) return
630
631
 
631
632
  const commentIds = Array.from(this.selection)
632
633
  try {
@@ -643,11 +644,11 @@ export default class extends Controller {
643
644
  // Job is async — the merged comment will update via broadcast
644
645
  } else {
645
646
  const data = await response.json().catch(() => ({}))
646
- alert(data.error || 'Failed to merge comments')
647
+ alertDialog(data.error || 'Failed to merge comments')
647
648
  }
648
649
  } catch (error) {
649
650
  console.error('Error merging comments:', error)
650
- alert('Failed to merge comments')
651
+ alertDialog('Failed to merge comments')
651
652
  }
652
653
  }
653
654
 
@@ -676,11 +677,11 @@ export default class extends Controller {
676
677
  }
677
678
  } else {
678
679
  const data = await response.json().catch(() => ({}))
679
- alert(data.error || 'Failed to branch comments')
680
+ alertDialog(data.error || 'Failed to branch comments')
680
681
  }
681
682
  } catch (error) {
682
683
  console.error('Error branching comments:', error)
683
- alert('Failed to branch comments')
684
+ alertDialog('Failed to branch comments')
684
685
  }
685
686
  }
686
687
 
@@ -808,11 +809,11 @@ export default class extends Controller {
808
809
  this.loadInitialComments()
809
810
  } else {
810
811
  const data = await response.json()
811
- alert(data.error || 'Failed to move comments')
812
+ alertDialog(data.error || 'Failed to move comments')
812
813
  }
813
814
  } catch (error) {
814
815
  console.error('Error moving comments to topic:', error)
815
- alert('Failed to move comments')
816
+ alertDialog('Failed to move comments')
816
817
  }
817
818
  }
818
819
 
@@ -902,8 +903,8 @@ export default class extends Controller {
902
903
 
903
904
  // API Methods
904
905
 
905
- deleteComment(button) {
906
- if (!confirm(this.element.dataset.deleteConfirmText)) return
906
+ async deleteComment(button) {
907
+ if (!(await confirmDialog(this.element.dataset.deleteConfirmText, { danger: true }))) return
907
908
  const commentId = button.getAttribute('data-comment-id')
908
909
  fetch(`/creatives/${this.creativeId}/comments/${commentId}`, {
909
910
  method: 'DELETE',
@@ -919,9 +920,9 @@ export default class extends Controller {
919
920
  })
920
921
  }
921
922
 
922
- convertComment(button) {
923
+ async convertComment(button) {
923
924
  // ... (Existing logic) ...
924
- if (!confirm(this.element.dataset.convertConfirmText)) return
925
+ if (!(await confirmDialog(this.element.dataset.convertConfirmText))) return
925
926
  const commentId = button.getAttribute('data-comment-id')
926
927
  fetch(`/creatives/${this.creativeId}/comments/${commentId}/convert`, {
927
928
  method: 'POST',
@@ -983,7 +984,7 @@ export default class extends Controller {
983
984
  const existing = document.getElementById(`comment_${commentId}`)
984
985
  if (existing) existing.outerHTML = html
985
986
  })
986
- .catch(e => { alert(e.message); button.disabled = false; })
987
+ .catch(e => { alertDialog(e.message); button.disabled = false; })
987
988
  }
988
989
 
989
990
  editComment(button) {
@@ -1013,7 +1014,7 @@ export default class extends Controller {
1013
1014
  })
1014
1015
  .catch((error) => {
1015
1016
  console.error(error)
1016
- alert(this.element.dataset.updateErrorText || 'Failed to update action')
1017
+ alertDialog(this.element.dataset.updateErrorText || 'Failed to update action')
1017
1018
  })
1018
1019
  .finally(() => { if (submitButton) submitButton.disabled = false })
1019
1020
  }
@@ -1022,7 +1023,7 @@ export default class extends Controller {
1022
1023
  openMoveModal(event) {
1023
1024
  if (this.movingComments) return
1024
1025
  if (this.selection.size === 0) {
1025
- alert(this.element.dataset.moveNoSelectionText || "No Selection")
1026
+ alertDialog(this.element.dataset.moveNoSelectionText || "No Selection")
1026
1027
  return
1027
1028
  }
1028
1029
  this.movingComments = true
@@ -2,6 +2,7 @@ import { Controller } from '@hotwired/stimulus'
2
2
  import { createSubscription } from '../../services/cable'
3
3
  import TouchDragHandler from '../../lib/touch_drag'
4
4
  import csrfFetch from '../../lib/api/csrf_fetch'
5
+ import { alertDialog } from '../../lib/utils/dialog'
5
6
 
6
7
  const TYPING_TIMEOUT = 3000
7
8
  const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
@@ -156,7 +157,7 @@ export default class extends Controller {
156
157
  this.renderTypingIndicator()
157
158
 
158
159
  if (closeOnForbidden) {
159
- alert(error.message)
160
+ alertDialog(error.message)
160
161
  this.popupController?.close()
161
162
  }
162
163
  })
@@ -1,6 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { createSubscription } from "../../services/cable"
3
3
  import { fetchNextTopicName, createTopicWithComments, saveLastTopic } from "../../lib/api/topics"
4
+ import { alertDialog, confirmDialog } from "../../lib/utils/dialog"
4
5
 
5
6
  const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
6
7
  const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
@@ -377,10 +378,15 @@ export default class extends Controller {
377
378
 
378
379
  async deleteTopic(event) {
379
380
  event.stopPropagation()
381
+ // Capture the topic id BEFORE awaiting the dialog: once the click
382
+ // dispatch completes, event.currentTarget is reset to null, so reading
383
+ // it after `await` throws. confirmDialog made this handler async, which
384
+ // exposed the latent stale-currentTarget hazard (the old sync confirm()
385
+ // never yielded the event loop).
386
+ const topicId = event.currentTarget.dataset.id
380
387
  const confirmText = this.listTarget.dataset.confirmDeleteText || "This will delete all messages in this topic. Are you sure?"
381
- if (!confirm(confirmText)) return
388
+ if (!(await confirmDialog(confirmText, { danger: true }))) return
382
389
 
383
- const topicId = event.currentTarget.dataset.id
384
390
  if (!topicId) return
385
391
 
386
392
  try {
@@ -398,7 +404,7 @@ export default class extends Controller {
398
404
  }
399
405
  this.loadTopics()
400
406
  } else {
401
- alert("Failed to delete topic")
407
+ alertDialog("Failed to delete topic")
402
408
  }
403
409
  } catch (e) {
404
410
  console.error("Error deleting topic", e)
@@ -425,7 +431,7 @@ export default class extends Controller {
425
431
  }
426
432
  this.loadTopics()
427
433
  } else {
428
- alert("Failed to archive topic")
434
+ alertDialog("Failed to archive topic")
429
435
  }
430
436
  } catch (e) {
431
437
  console.error("Error archiving topic", e)
@@ -448,7 +454,7 @@ export default class extends Controller {
448
454
  if (response.ok) {
449
455
  this.loadTopics()
450
456
  } else {
451
- alert("Failed to restore topic")
457
+ alertDialog("Failed to restore topic")
452
458
  }
453
459
  } catch (e) {
454
460
  console.error("Error restoring topic", e)
@@ -608,7 +614,7 @@ export default class extends Controller {
608
614
  this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
609
615
  this.restoreSelection()
610
616
  } else {
611
- alert("Failed to update topic")
617
+ alertDialog("Failed to update topic")
612
618
  this.loadTopics() // Reload to restore state
613
619
  }
614
620
  } catch (e) {
@@ -699,7 +705,7 @@ export default class extends Controller {
699
705
  // Dispatch change event manually since we skipped the click handler
700
706
  this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
701
707
  } else {
702
- alert("Failed to create topic")
708
+ alertDialog("Failed to create topic")
703
709
  }
704
710
  } catch (e) {
705
711
  console.error("Error creating topic", e)
@@ -943,7 +949,7 @@ export default class extends Controller {
943
949
  )
944
950
  if (listController) listController.clearSelection()
945
951
  } else {
946
- alert(result.error)
952
+ alertDialog(result.error)
947
953
  }
948
954
  }
949
955
 
@@ -6,6 +6,7 @@ import { jest } from '@jest/globals'
6
6
 
7
7
  jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
8
8
  renderCreativeTree: jest.fn(),
9
+ appendCreativeNodes: jest.fn(),
9
10
  dispatchCreativeTreeUpdated: jest.fn(),
10
11
  applyRowProperties: jest.fn(),
11
12
  }))
@@ -16,6 +17,7 @@ jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
16
17
 
17
18
  const { Application } = await import('@hotwired/stimulus')
18
19
  const TreeController = (await import('../tree_controller')).default
20
+ const { appendCreativeNodes } = await import('../../../creatives/tree_renderer')
19
21
 
20
22
  const TRANSIENT_RETRY_DELAYS = [200, 600]
21
23
 
@@ -118,3 +120,151 @@ describe('CreativesTreeController retry on transient network errors', () => {
118
120
  application.stop()
119
121
  })
120
122
  })
123
+
124
+ describe('CreativesTreeController Chats pagination (load more)', () => {
125
+ let originalFetch
126
+ let originalIO
127
+
128
+ class MockIntersectionObserver {
129
+ constructor(callback) {
130
+ this.callback = callback
131
+ MockIntersectionObserver.instances.push(this)
132
+ }
133
+ observe(el) { this.observed = el }
134
+ disconnect() { this.disconnected = true }
135
+ triggerIntersect() { this.callback([{ isIntersecting: true }]) }
136
+ }
137
+
138
+ beforeEach(() => {
139
+ originalFetch = global.fetch
140
+ originalIO = global.IntersectionObserver
141
+ MockIntersectionObserver.instances = []
142
+ global.IntersectionObserver = MockIntersectionObserver
143
+ appendCreativeNodes.mockClear()
144
+ })
145
+
146
+ afterEach(() => {
147
+ global.fetch = originalFetch
148
+ global.IntersectionObserver = originalIO
149
+ document.body.innerHTML = ''
150
+ jest.restoreAllMocks()
151
+ })
152
+
153
+ test('observes a sentinel only when the response carries has_more pagination', async () => {
154
+ global.fetch = jest.fn().mockResolvedValue({
155
+ ok: true,
156
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
157
+ })
158
+
159
+ const { container, application } = installController()
160
+ await flush()
161
+ await flush()
162
+
163
+ expect(MockIntersectionObserver.instances).toHaveLength(1)
164
+ expect(container.querySelector('.creative-chats-load-sentinel')).not.toBeNull()
165
+
166
+ application.stop()
167
+ })
168
+
169
+ test('does NOT set up pagination for a plain tree response (no pagination key)', async () => {
170
+ global.fetch = jest.fn().mockResolvedValue({
171
+ ok: true,
172
+ json: async () => ({ creatives: [{ id: 1 }] }),
173
+ })
174
+
175
+ const { container, application } = installController()
176
+ await flush()
177
+ await flush()
178
+
179
+ expect(MockIntersectionObserver.instances).toHaveLength(0)
180
+ expect(container.querySelector('.creative-chats-load-sentinel')).toBeNull()
181
+
182
+ application.stop()
183
+ })
184
+
185
+ test('fetches the next page and appends rows when the sentinel intersects', async () => {
186
+ const page2Nodes = [{ id: 2 }, { id: 3 }]
187
+ global.fetch = jest
188
+ .fn()
189
+ .mockResolvedValueOnce({
190
+ ok: true,
191
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
192
+ })
193
+ .mockResolvedValueOnce({
194
+ ok: true,
195
+ json: async () => ({ creatives: page2Nodes, pagination: { has_more: false, next_page: null } }),
196
+ })
197
+
198
+ const { application } = installController()
199
+ await flush()
200
+ await flush()
201
+
202
+ const observer = MockIntersectionObserver.instances[0]
203
+ observer.triggerIntersect()
204
+ await flush()
205
+ await flush()
206
+
207
+ const secondCallUrl = global.fetch.mock.calls[1][0]
208
+ expect(secondCallUrl).toContain('page=2')
209
+ expect(appendCreativeNodes).toHaveBeenCalledTimes(1)
210
+ expect(appendCreativeNodes.mock.calls[0][1]).toEqual(page2Nodes)
211
+ // has_more:false on page 2 tears the observer down.
212
+ expect(observer.disconnected).toBe(true)
213
+
214
+ application.stop()
215
+ })
216
+
217
+ test('drops a stale load-more response that resolves after a fresh load', async () => {
218
+ let resolvePage2
219
+ const page2Nodes = [{ id: 2 }, { id: 3 }]
220
+ global.fetch = jest
221
+ .fn()
222
+ // initial page-1 (Chats filter, has more)
223
+ .mockResolvedValueOnce({
224
+ ok: true,
225
+ json: async () => ({ creatives: [{ id: 1 }], pagination: { has_more: true, next_page: 2 } }),
226
+ })
227
+ // page-2 stays pending until we resolve it manually
228
+ .mockImplementationOnce(
229
+ () =>
230
+ new Promise((resolve) => {
231
+ resolvePage2 = () =>
232
+ resolve({
233
+ ok: true,
234
+ json: async () => ({ creatives: page2Nodes, pagination: { has_more: true, next_page: 3 } }),
235
+ })
236
+ })
237
+ )
238
+ // the fresh load() re-renders a different (non-paginated) view
239
+ .mockResolvedValueOnce({
240
+ ok: true,
241
+ json: async () => ({ creatives: [{ id: 99 }] }),
242
+ })
243
+
244
+ const { container, application } = installController()
245
+ await flush()
246
+ await flush()
247
+
248
+ // Sentinel intersects -> page-2 fetch kicks off but stays pending.
249
+ MockIntersectionObserver.instances[0].triggerIntersect()
250
+ await flush()
251
+
252
+ // A fresh load happens before page 2 resolves (filter change, sync refetch,
253
+ // archive toggle). load() tears down pagination and aborts the in-flight
254
+ // load-more.
255
+ const controller = application.getControllerForElementAndIdentifier(container, 'creatives--tree')
256
+ controller.load()
257
+ await flush()
258
+ await flush()
259
+
260
+ // The stale page-2 response finally arrives. It must NOT be appended into
261
+ // the freshly rendered view.
262
+ resolvePage2()
263
+ await flush()
264
+ await flush()
265
+
266
+ expect(appendCreativeNodes).not.toHaveBeenCalled()
267
+
268
+ application.stop()
269
+ })
270
+ })
@@ -1,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import csrfFetch from '../../lib/api/csrf_fetch'
3
+ import { alertDialog } from '../../lib/utils/dialog'
3
4
 
4
5
  export default class extends Controller {
5
6
  static targets = ['area', 'dropzone', 'input', 'progress', 'toggle']
@@ -66,7 +67,7 @@ export default class extends Controller {
66
67
  const isPpt = lower.endsWith('.ppt') || lower.endsWith('.pptx')
67
68
 
68
69
  if (!isMarkdown && !isPpt) {
69
- window.alert(this.onlyMarkdownValue)
70
+ alertDialog(this.onlyMarkdownValue)
70
71
  return
71
72
  }
72
73
 
@@ -1,5 +1,6 @@
1
1
  import { Controller } from '@hotwired/stimulus'
2
2
  import csrfFetch from '../../lib/api/csrf_fetch'
3
+ import { confirmDialog } from '../../lib/utils/confirm_dialog'
3
4
 
4
5
  export default class extends Controller {
5
6
  static targets = [
@@ -84,7 +85,7 @@ export default class extends Controller {
84
85
  if (ids.length === 0) return
85
86
 
86
87
  const confirmMessage = this.hasDeleteButtonTarget ? this.deleteButtonTarget.dataset.confirm : undefined
87
- if (confirmMessage && !window.confirm(confirmMessage)) {
88
+ if (confirmMessage && !(await confirmDialog(confirmMessage, { danger: true }))) {
88
89
  return
89
90
  }
90
91