collavre 0.22.0 → 0.24.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 (221) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  6. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  7. data/app/assets/stylesheets/collavre/actiontext.css +251 -90
  8. data/app/assets/stylesheets/collavre/code_highlight.css +7 -201
  9. data/app/assets/stylesheets/collavre/comments_popup.css +158 -61
  10. data/app/assets/stylesheets/collavre/creatives.css +11 -2
  11. data/app/assets/stylesheets/collavre/landing.css +92 -0
  12. data/app/assets/stylesheets/collavre/modal_dialog.css +32 -0
  13. data/app/assets/stylesheets/collavre/popup.css +4 -2
  14. data/app/assets/stylesheets/collavre/tables.css +91 -0
  15. data/app/channels/collavre/inbox_badge_channel.rb +30 -0
  16. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  17. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  18. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  19. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +224 -0
  20. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +95 -0
  21. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +31 -0
  22. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +25 -0
  23. data/app/controllers/collavre/attachments_controller.rb +13 -3
  24. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  25. data/app/controllers/collavre/creatives_controller.rb +47 -103
  26. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  27. data/app/controllers/collavre/landing_controller.rb +5 -2
  28. data/app/controllers/collavre/tasks_controller.rb +13 -4
  29. data/app/controllers/collavre/topics_controller.rb +54 -1
  30. data/app/controllers/collavre/typo_corrections_controller.rb +39 -0
  31. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  32. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +16 -1
  33. data/app/controllers/concerns/collavre/users_controller/registration.rb +41 -1
  34. data/app/errors/collavre/api_error.rb +20 -0
  35. data/app/helpers/collavre/application_helper.rb +1 -0
  36. data/app/helpers/collavre/creatives_helper.rb +30 -8
  37. data/app/javascript/collavre.js +2 -0
  38. data/app/javascript/components/ImageResizer.jsx +9 -3
  39. data/app/javascript/components/InlineLexicalEditor.jsx +157 -72
  40. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  41. data/app/javascript/components/creative_tree_row.js +28 -5
  42. data/app/javascript/components/plugins/list_tab_indent_plugin.jsx +16 -0
  43. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  44. data/app/javascript/components/plugins/table_hover_actions_plugin.jsx +405 -0
  45. data/app/javascript/controllers/__tests__/inbox_badge_controller.test.js +73 -0
  46. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  47. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  48. data/app/javascript/controllers/comment_controller.js +5 -4
  49. data/app/javascript/controllers/comment_version_controller.js +2 -1
  50. data/app/javascript/controllers/comments/__tests__/form_controller_double_submit.test.js +159 -0
  51. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +3 -2
  52. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +94 -0
  53. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  54. data/app/javascript/controllers/comments/form_controller.js +21 -5
  55. data/app/javascript/controllers/comments/list_controller.js +18 -17
  56. data/app/javascript/controllers/comments/presence_controller.js +2 -1
  57. data/app/javascript/controllers/comments/topics_controller.js +61 -8
  58. data/app/javascript/controllers/common_popup_controller.js +2 -2
  59. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +150 -0
  60. data/app/javascript/controllers/creatives/import_controller.js +2 -1
  61. data/app/javascript/controllers/creatives/select_mode_controller.js +2 -1
  62. data/app/javascript/controllers/creatives/tree_controller.js +142 -1
  63. data/app/javascript/controllers/image_lightbox_controller.js +2 -1
  64. data/app/javascript/controllers/inbox_badge_controller.js +33 -0
  65. data/app/javascript/controllers/index.js +7 -1
  66. data/app/javascript/controllers/search_popup_controller.js +17 -1
  67. data/app/javascript/controllers/share_modal_controller.js +4 -3
  68. data/app/javascript/controllers/topic_list_controller.js +78 -0
  69. data/app/javascript/controllers/topic_search_controller.js +2 -1
  70. data/app/javascript/creatives/drag_drop/event_handlers.js +14 -5
  71. data/app/javascript/creatives/topic_move_members_popup.js +156 -0
  72. data/app/javascript/creatives/tree_renderer.js +11 -0
  73. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  74. data/app/javascript/lib/__tests__/turbo_confirm.test.js +81 -0
  75. data/app/javascript/lib/__tests__/typo_correction.test.js +192 -0
  76. data/app/javascript/lib/api/__tests__/api_error.test.js +96 -0
  77. data/app/javascript/lib/api/__tests__/queue_manager.test.js +88 -1
  78. data/app/javascript/lib/api/api_error.js +108 -0
  79. data/app/javascript/lib/api/queue_manager.js +38 -4
  80. data/app/javascript/lib/common_popup.js +42 -10
  81. data/app/javascript/lib/editor/__tests__/code_edit_view_token_parity.test.js +121 -0
  82. data/app/javascript/lib/editor/__tests__/code_language_roundtrip.test.js +152 -0
  83. data/app/javascript/lib/editor/__tests__/code_languages.test.js +93 -0
  84. data/app/javascript/lib/editor/code_languages.js +173 -0
  85. data/app/javascript/lib/editor/code_token_theme.js +41 -0
  86. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  87. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  88. data/app/javascript/lib/lexical/__tests__/image_focus.test.js +139 -0
  89. data/app/javascript/lib/lexical/__tests__/list_tab_indent.test.js +633 -0
  90. data/app/javascript/lib/lexical/__tests__/markdown_serialize.test.js +627 -0
  91. data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +20 -1
  92. data/app/javascript/lib/lexical/__tests__/selection_boundary.test.js +88 -0
  93. data/app/javascript/lib/lexical/__tests__/table_transformer.test.js +163 -0
  94. data/app/javascript/lib/lexical/__tests__/trailing_paragraph.test.js +104 -0
  95. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  96. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  97. data/app/javascript/lib/lexical/list_tab_indent.js +210 -0
  98. data/app/javascript/lib/lexical/markdown_serialize.js +320 -0
  99. data/app/javascript/lib/lexical/selection_boundary.js +58 -0
  100. data/app/javascript/lib/lexical/table_transformer.js +182 -0
  101. data/app/javascript/lib/lexical/trailing_paragraph.js +29 -0
  102. data/app/javascript/lib/turbo_confirm.js +46 -0
  103. data/app/javascript/lib/typo_correction.js +146 -0
  104. data/app/javascript/lib/utils/__tests__/confirm_dialog.test.js +88 -0
  105. data/app/javascript/lib/utils/__tests__/dialog.test.js +92 -0
  106. data/app/javascript/lib/utils/__tests__/markdown.test.js +153 -0
  107. data/app/javascript/lib/utils/__tests__/sanitize_description.test.js +68 -0
  108. data/app/javascript/lib/utils/__tests__/table_download.test.js +93 -0
  109. data/app/javascript/lib/utils/confirm_dialog.js +10 -0
  110. data/app/javascript/lib/utils/dialog.js +300 -0
  111. data/app/javascript/lib/utils/markdown.js +154 -67
  112. data/app/javascript/lib/utils/sanitize_description.js +31 -0
  113. data/app/javascript/lib/utils/table_download.js +15 -0
  114. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  115. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  116. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  117. data/app/javascript/modules/__tests__/typo_corrector.test.js +365 -0
  118. data/app/javascript/modules/creative_inline_payload.js +86 -0
  119. data/app/javascript/modules/creative_row_editor.js +231 -471
  120. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  121. data/app/javascript/modules/creative_save_queue.js +95 -0
  122. data/app/javascript/modules/creative_tree_dom.js +260 -0
  123. data/app/javascript/modules/export_to_markdown.js +2 -1
  124. data/app/javascript/modules/lexical_inline_editor.jsx +2 -1
  125. data/app/javascript/modules/slide_view.js +11 -2
  126. data/app/javascript/modules/typo_corrector.js +534 -0
  127. data/app/jobs/collavre/ai_agent_job.rb +7 -4
  128. data/app/jobs/collavre/compress_job.rb +6 -2
  129. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  130. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  131. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  132. data/app/models/collavre/channel.rb +16 -0
  133. data/app/models/collavre/comment/approvable.rb +16 -0
  134. data/app/models/collavre/comment/broadcastable.rb +46 -7
  135. data/app/models/collavre/comment/notifiable.rb +15 -5
  136. data/app/models/collavre/comment.rb +87 -32
  137. data/app/models/collavre/comment_version.rb +1 -1
  138. data/app/models/collavre/creative/describable.rb +104 -19
  139. data/app/models/collavre/creative/linkable.rb +9 -0
  140. data/app/models/collavre/creative/permissible.rb +97 -49
  141. data/app/models/collavre/creative.rb +73 -5
  142. data/app/models/collavre/creative_share.rb +78 -26
  143. data/app/models/collavre/inbox_item.rb +2 -2
  144. data/app/models/collavre/task.rb +27 -0
  145. data/app/models/collavre/user.rb +90 -10
  146. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  147. data/app/services/collavre/agent_type_classifier.rb +23 -0
  148. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  149. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  150. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  151. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  152. data/app/services/collavre/ai_client.rb +81 -12
  153. data/app/services/collavre/auto_theme_generator.rb +15 -11
  154. data/app/services/collavre/creatives/children_index.rb +86 -0
  155. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  156. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  157. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  158. data/app/services/collavre/creatives/index_query.rb +85 -16
  159. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  160. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  161. data/app/services/collavre/creatives/tree_builder.rb +107 -78
  162. data/app/services/collavre/gemini_parent_recommender.rb +1 -1
  163. data/app/services/collavre/http_client.rb +119 -0
  164. data/app/services/collavre/inbox_reply_service.rb +5 -0
  165. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  166. data/app/services/collavre/markdown_converter.rb +13 -3
  167. data/app/services/collavre/markdown_importer.rb +30 -6
  168. data/app/services/collavre/mobile/event_summarizer.rb +40 -0
  169. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  170. data/app/services/collavre/orchestration/agent_orchestrator.rb +34 -8
  171. data/app/services/collavre/orchestration/arbiter.rb +16 -0
  172. data/app/services/collavre/orchestration/matcher.rb +79 -4
  173. data/app/services/collavre/orchestration/policy_resolver.rb +4 -3
  174. data/app/services/collavre/orchestration/stuck_detector.rb +141 -34
  175. data/app/services/collavre/system_events/context_builder.rb +1 -14
  176. data/app/services/collavre/tools/creative_batch_service.rb +3 -2
  177. data/app/services/collavre/tools/creative_create_service.rb +8 -8
  178. data/app/services/collavre/tools/creative_update_service.rb +23 -8
  179. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  180. data/app/services/collavre/typo_corrector.rb +188 -0
  181. data/app/views/collavre/comments/_comment.html.erb +9 -4
  182. data/app/views/collavre/comments/_comments_popup.html.erb +25 -4
  183. data/app/views/collavre/creatives/_inline_edit_form.html.erb +10 -0
  184. data/app/views/collavre/creatives/_topic_move_members_modal.html.erb +42 -0
  185. data/app/views/collavre/creatives/index.html.erb +14 -1
  186. data/app/views/collavre/creatives/slide_view.html.erb +1 -1
  187. data/app/views/collavre/landing/show.html.erb +52 -10
  188. data/app/views/collavre/users/show.html.erb +3 -0
  189. data/app/views/collavre/users/typo_correction.html.erb +50 -0
  190. data/app/views/layouts/collavre/slide.html.erb +1 -0
  191. data/config/locales/comments.en.yml +16 -0
  192. data/config/locales/comments.ko.yml +16 -0
  193. data/config/locales/creatives.en.yml +6 -0
  194. data/config/locales/creatives.ko.yml +5 -0
  195. data/config/locales/integrations.en.yml +1 -1
  196. data/config/locales/integrations.ko.yml +1 -1
  197. data/config/locales/landing.en.yml +22 -10
  198. data/config/locales/landing.ko.yml +22 -10
  199. data/config/locales/mobile.en.yml +16 -0
  200. data/config/locales/mobile.ko.yml +16 -0
  201. data/config/locales/orchestration.en.yml +1 -0
  202. data/config/locales/orchestration.ko.yml +1 -0
  203. data/config/locales/users.en.yml +15 -0
  204. data/config/locales/users.ko.yml +15 -0
  205. data/config/routes.rb +13 -0
  206. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  207. data/db/migrate/20260612000000_add_topic_concurrency_defer_to_comments.rb +38 -0
  208. data/db/migrate/20260617090000_add_typo_correction_settings_to_users.rb +18 -0
  209. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  210. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  211. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  212. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  213. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  214. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  215. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  216. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  217. data/db/seeds.rb +51 -0
  218. data/lib/collavre/version.rb +1 -1
  219. data/lib/generators/collavre/install/install_generator.rb +1 -0
  220. metadata +96 -2
  221. data/app/services/collavre/tools/description_normalizable.rb +0 -16
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const createSubscription = jest.fn()
8
+ const renderStreamMessage = jest.fn()
9
+
10
+ jest.unstable_mockModule('../../services/cable', () => ({
11
+ createSubscription,
12
+ }))
13
+
14
+ jest.unstable_mockModule('@hotwired/turbo-rails', () => ({
15
+ Turbo: { renderStreamMessage },
16
+ }))
17
+
18
+ const { Application } = await import('@hotwired/stimulus')
19
+ const InboxBadgeController = (await import('../inbox_badge_controller')).default
20
+
21
+ describe('InboxBadgeController', () => {
22
+ let application
23
+ let container
24
+ let subscription
25
+
26
+ beforeEach(() => {
27
+ subscription = { unsubscribe: jest.fn() }
28
+ createSubscription.mockReset()
29
+ renderStreamMessage.mockReset()
30
+ createSubscription.mockReturnValue(subscription)
31
+
32
+ container = document.createElement('div')
33
+ container.setAttribute('data-controller', 'inbox-badge')
34
+ document.body.appendChild(container)
35
+
36
+ application = Application.start()
37
+ application.register('inbox-badge', InboxBadgeController)
38
+ })
39
+
40
+ afterEach(() => {
41
+ application.stop()
42
+ document.body.innerHTML = ''
43
+ })
44
+
45
+ test('subscribes to the namespaced InboxBadgeChannel on connect', async () => {
46
+ await new Promise((resolve) => setTimeout(resolve, 0))
47
+
48
+ expect(createSubscription).toHaveBeenCalledTimes(1)
49
+ expect(createSubscription).toHaveBeenCalledWith(
50
+ { channel: 'Collavre::InboxBadgeChannel' },
51
+ expect.objectContaining({ received: expect.any(Function) }),
52
+ )
53
+ })
54
+
55
+ test('renders the transmitted badge snapshot through Turbo on its own subscription', async () => {
56
+ await new Promise((resolve) => setTimeout(resolve, 0))
57
+
58
+ const [, callbacks] = createSubscription.mock.calls[0]
59
+ const snapshot = '<turbo-stream action="replace" target="desktop-inbox-badge"></turbo-stream>'
60
+ callbacks.received(snapshot)
61
+
62
+ expect(renderStreamMessage).toHaveBeenCalledWith(snapshot)
63
+ })
64
+
65
+ test('unsubscribes when the controller disconnects', async () => {
66
+ await new Promise((resolve) => setTimeout(resolve, 0))
67
+
68
+ container.remove()
69
+ await new Promise((resolve) => setTimeout(resolve, 0))
70
+
71
+ expect(subscription.unsubscribe).toHaveBeenCalledTimes(1)
72
+ })
73
+ })
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+
7
+ const { default: SearchPopupController } = await import('../search_popup_controller')
8
+
9
+ describe('SearchPopupController Ctrl+K shortcut', () => {
10
+ let application
11
+ let container
12
+ let popup
13
+
14
+ function pressCtrlK(target) {
15
+ const event = new KeyboardEvent('keydown', {
16
+ key: 'k',
17
+ ctrlKey: true,
18
+ bubbles: true,
19
+ cancelable: true
20
+ })
21
+ target.dispatchEvent(event)
22
+ return event
23
+ }
24
+
25
+ beforeEach(async () => {
26
+ document.body.innerHTML = ''
27
+
28
+ container = document.createElement('div')
29
+ container.innerHTML = `
30
+ <div data-controller="search-popup">
31
+ <input type="text" data-search-popup-target="input" />
32
+ <div data-search-popup-target="popup"></div>
33
+ </div>
34
+ `
35
+ document.body.appendChild(container)
36
+
37
+ application = Application.start()
38
+ application.register('search-popup', SearchPopupController)
39
+ await new Promise(resolve => setTimeout(resolve, 0))
40
+
41
+ popup = container.querySelector('[data-search-popup-target="popup"]')
42
+ })
43
+
44
+ afterEach(() => {
45
+ application?.stop()
46
+ document.body.innerHTML = ''
47
+ jest.restoreAllMocks()
48
+ })
49
+
50
+ test('opens the popup on Ctrl+K when focus is not in an editable element', () => {
51
+ const event = pressCtrlK(document.body)
52
+
53
+ expect(popup.classList.contains('open')).toBe(true)
54
+ expect(event.defaultPrevented).toBe(true)
55
+ })
56
+
57
+ test('does not open the popup when Ctrl+K is pressed inside a text input', () => {
58
+ const input = document.createElement('input')
59
+ input.type = 'text'
60
+ document.body.appendChild(input)
61
+ input.focus()
62
+
63
+ const event = pressCtrlK(input)
64
+
65
+ expect(popup.classList.contains('open')).toBe(false)
66
+ expect(event.defaultPrevented).toBe(false)
67
+ })
68
+
69
+ test('does not open the popup when Ctrl+K is pressed inside a textarea', () => {
70
+ const textarea = document.createElement('textarea')
71
+ document.body.appendChild(textarea)
72
+ textarea.focus()
73
+
74
+ const event = pressCtrlK(textarea)
75
+
76
+ expect(popup.classList.contains('open')).toBe(false)
77
+ expect(event.defaultPrevented).toBe(false)
78
+ })
79
+
80
+ test('does not open the popup when Ctrl+K is pressed inside a contenteditable element', () => {
81
+ const editable = document.createElement('div')
82
+ editable.setAttribute('contenteditable', 'true')
83
+ document.body.appendChild(editable)
84
+ editable.focus()
85
+
86
+ const event = pressCtrlK(editable)
87
+
88
+ expect(popup.classList.contains('open')).toBe(false)
89
+ expect(event.defaultPrevented).toBe(false)
90
+ })
91
+
92
+ test('does not open the popup when Ctrl+K is pressed inside the inline Lexical editor', () => {
93
+ const lexicalRoot = document.createElement('div')
94
+ lexicalRoot.setAttribute('data-lexical-editor-root', '')
95
+ const inner = document.createElement('p')
96
+ lexicalRoot.appendChild(inner)
97
+ document.body.appendChild(lexicalRoot)
98
+
99
+ const event = pressCtrlK(inner)
100
+
101
+ expect(popup.classList.contains('open')).toBe(false)
102
+ expect(event.defaultPrevented).toBe(false)
103
+ })
104
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+ import TopicListController from '../topic_list_controller'
7
+
8
+ describe('TopicListController', () => {
9
+ let application, controller
10
+
11
+ const mount = () => {
12
+ document.body.innerHTML = `
13
+ <div id="topic-list-modal" class="common-popup" data-controller="topic-list">
14
+ <button data-topic-list-target="close">×</button>
15
+ <input data-topic-list-target="input">
16
+ <ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
17
+ </div>
18
+ `
19
+ application = Application.start()
20
+ application.register('topic-list', TopicListController)
21
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
22
+ controller = application.getControllerForElementAndIdentifier(
23
+ document.getElementById('topic-list-modal'), 'topic-list'
24
+ )
25
+ })
26
+ }
27
+
28
+ const RECT = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }
29
+ const DATA = {
30
+ topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
31
+ archivedTopics: [{ id: 3, name: 'Zeta' }],
32
+ mainTopicId: '1',
33
+ allMessagesLabel: 'All Messages'
34
+ }
35
+
36
+ const items = () => Array.from(document.querySelectorAll('#topic-list-modal li.common-popup-item'))
37
+
38
+ beforeEach(() => {
39
+ global.requestAnimationFrame = (fn) => { fn(); return 0 }
40
+ return mount()
41
+ })
42
+
43
+ afterEach(() => {
44
+ document.body.innerHTML = ''
45
+ application.stop()
46
+ jest.clearAllMocks()
47
+ })
48
+
49
+ test('builds items in bar order: main, others, All Messages, archived', () => {
50
+ controller.openForTopics(DATA, RECT, () => {})
51
+ const labels = items().map((li) => li.textContent.trim())
52
+ expect(labels).toEqual(['#Main', '#Alpha', '📋 All Messages', '#Zeta'])
53
+ })
54
+
55
+ test('marks archived items with the distinguishing class', () => {
56
+ controller.openForTopics(DATA, RECT, () => {})
57
+ const archived = items().filter((li) => li.querySelector('.topic-list-item--archived'))
58
+ expect(archived).toHaveLength(1)
59
+ expect(archived[0].textContent).toContain('#Zeta')
60
+ })
61
+
62
+ test('filters by label substring and adds NO create option', () => {
63
+ controller.openForTopics(DATA, RECT, () => {})
64
+ controller.inputTarget.value = 'alpha'
65
+ controller._onInput()
66
+ const labels = items().map((li) => li.textContent.trim())
67
+ expect(labels).toEqual(['#Alpha'])
68
+
69
+ controller.inputTarget.value = 'brandnew'
70
+ controller._onInput()
71
+ expect(items()).toHaveLength(0) // no "create and move" pseudo-item
72
+ })
73
+
74
+ test('select invokes the callback with the item and closes', () => {
75
+ const cb = jest.fn()
76
+ controller.openForTopics(DATA, RECT, cb)
77
+ const item = { id: 2, label: '#Alpha', archived: false }
78
+ controller.select(item)
79
+ expect(cb).toHaveBeenCalledWith(item)
80
+ expect(controller.popup.isOpen()).toBe(false)
81
+ })
82
+ })
@@ -3,6 +3,7 @@ import { renderCommentMarkdown, renderMermaidDiagrams } from '../lib/utils/markd
3
3
  import { addTableDownloadButtons } from '../lib/utils/table_download'
4
4
  import CommonPopup from '../lib/common_popup'
5
5
  import csrfFetch from '../lib/api/csrf_fetch'
6
+ import { alertDialog, confirmDialog } from '../lib/utils/dialog'
6
7
 
7
8
  // Global tracker: persists streaming state across Turbo replacements
8
9
  // (each replacement creates a new controller instance, losing instance state)
@@ -248,7 +249,7 @@ export default class extends Controller {
248
249
  const data = await response.json()
249
250
  this.updateReactionsUI(data)
250
251
  } catch (error) {
251
- alert(error?.message || 'Failed to update reaction')
252
+ alertDialog(error?.message || 'Failed to update reaction')
252
253
  }
253
254
  }
254
255
 
@@ -495,7 +496,7 @@ export default class extends Controller {
495
496
  const creativeId = button.dataset.creativeId
496
497
  const confirmText = button.dataset.confirmText || 'Restore original messages?'
497
498
 
498
- if (!confirm(confirmText)) return
499
+ if (!(await confirmDialog(confirmText, { danger: true }))) return
499
500
 
500
501
  button.disabled = true
501
502
  try {
@@ -511,12 +512,12 @@ export default class extends Controller {
511
512
  // the summary comment (this one) will be destroyed via broadcast too
512
513
  } else {
513
514
  const data = await response.json().catch(() => ({}))
514
- alert(data.error || 'Failed to restore')
515
+ alertDialog(data.error || 'Failed to restore')
515
516
  button.disabled = false
516
517
  }
517
518
  } catch (error) {
518
519
  console.error('Error restoring snapshot:', error)
519
- alert('Failed to restore')
520
+ alertDialog('Failed to restore')
520
521
  button.disabled = false
521
522
  }
522
523
  }
@@ -1,5 +1,6 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
  import { renderCommentMarkdown, renderMermaidDiagrams } from "../lib/utils/markdown"
3
+ import { confirmDialog } from "../lib/utils/dialog"
3
4
 
4
5
  export default class extends Controller {
5
6
  static targets = ["prevBtn", "nextBtn", "indicator", "deleteBtn", "selectBtn"]
@@ -80,7 +81,7 @@ export default class extends Controller {
80
81
  // Can't delete if it's the only version
81
82
  if (versions.length <= 1) return
82
83
 
83
- if (!confirm(this.deleteBtnTarget.dataset.confirmMessage || "Are you sure?")) return
84
+ if (!(await confirmDialog(this.deleteBtnTarget.dataset.confirmMessage || "Are you sure?", { danger: true }))) return
84
85
 
85
86
  const response = await fetch(
86
87
  `${this.versionsUrlValue}/${version.id}`,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { Application } from '@hotwired/stimulus'
7
+ import FormController from '../form_controller'
8
+
9
+ // Regression harness for: "slow send API + double Enter => message sent twice".
10
+ //
11
+ // Scenario A proves the in-flight guard blocks a second Enter on the same
12
+ // controller instance. Scenario B reproduces the real-world bug: a Turbo/
13
+ // websocket morph reconnects the Stimulus controller mid-send, and (before the
14
+ // fix) connect() reset this.sending, blowing away the guard so an impatient
15
+ // second Enter re-sent the same message.
16
+ describe('FormController - double submit on slow API', () => {
17
+ let application
18
+ let container
19
+ let controller
20
+
21
+ const FIXTURE = `
22
+ <div id="comments-popup"
23
+ data-controller="comments--form"
24
+ data-review-feedback-placeholder="Write feedback for this quote..."
25
+ data-review-summary-placeholder="Overall comment (optional)..."
26
+ data-review-add-quote="+ Add"
27
+ data-review-send="Send review"
28
+ data-review-send-question="Send question"
29
+ data-review-type-review="💬"
30
+ data-review-type-question="❓"
31
+ data-review-type-review-label="Review"
32
+ data-review-type-question-label="Question">
33
+ <form data-comments--form-target="form">
34
+ <input type="hidden" name="comment[quoted_comment_id]" data-comments--form-target="quotedCommentId" value="" />
35
+ <input type="hidden" name="comment[quoted_text]" data-comments--form-target="quotedText" value="" />
36
+ <div data-comments--form-target="quoteIndicator" style="display:none;">
37
+ <span data-comments--form-target="quoteIndicatorText"></span>
38
+ </div>
39
+ <div class="review-quotes-container" data-comments--form-target="reviewQuotesContainer" style="display:none;"></div>
40
+ <textarea data-comments--form-target="textarea" rows="2"></textarea>
41
+ <input type="checkbox" data-comments--form-target="privateCheckbox" />
42
+ <button type="submit" data-comments--form-target="submit">Send</button>
43
+ <button data-comments--form-target="cancel" style="display:none;">Cancel</button>
44
+ <button data-comments--form-target="moveButton" style="display:none;">Move</button>
45
+ <button data-comments--form-target="searchButton" style="display:none;">Search</button>
46
+ <button data-comments--form-target="voiceButton" style="display:none;">Voice</button>
47
+ <input type="file" data-comments--form-target="imageInput" style="display:none;" />
48
+ <button data-comments--form-target="imageButton" style="display:none;">Image</button>
49
+ <div data-comments--form-target="attachmentList"></div>
50
+ </form>
51
+ </div>`
52
+
53
+ beforeEach(async () => {
54
+ const meta = document.createElement('meta')
55
+ meta.name = 'csrf-token'
56
+ meta.content = 'test-token'
57
+ document.head.appendChild(meta)
58
+
59
+ if (!global.DataTransfer) {
60
+ global.DataTransfer = class {
61
+ constructor() {
62
+ this.files = []
63
+ this.items = { add: (file) => this.files.push(file) }
64
+ }
65
+ }
66
+ }
67
+
68
+ container = document.createElement('div')
69
+ container.innerHTML = FIXTURE
70
+ document.body.appendChild(container)
71
+
72
+ application = Application.start()
73
+ application.register('comments--form', FormController)
74
+ await new Promise((r) => setTimeout(r, 50))
75
+ controller = application.getControllerForElementAndIdentifier(
76
+ container.querySelector('#comments-popup'),
77
+ 'comments--form',
78
+ )
79
+ controller.creativeId = '123'
80
+ controller.currentTopicId = '10073'
81
+ })
82
+
83
+ afterEach(() => {
84
+ application.stop()
85
+ document.body.removeChild(container)
86
+ })
87
+
88
+ const pressEnter = (textarea) => {
89
+ textarea.dispatchEvent(
90
+ new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
91
+ )
92
+ }
93
+
94
+ test('SCENARIO A: two Enter presses while a fetch is in-flight => fetch fires only once', () => {
95
+ const fetchSpy = jest.fn(() => new Promise(() => {})) // never resolves: slow API
96
+ global.fetch = fetchSpy
97
+ controller.creativeId = '111'
98
+
99
+ const textarea = controller.textareaTarget
100
+ textarea.value = 'hello world'
101
+
102
+ pressEnter(textarea)
103
+ pressEnter(textarea) // impatient second Enter
104
+
105
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
106
+ })
107
+
108
+ test('SCENARIO B: a controller reconnect (morph) mid-send must NOT re-enable sending', () => {
109
+ const fetchSpy = jest.fn(() => new Promise(() => {})) // never resolves: slow API
110
+ global.fetch = fetchSpy
111
+ controller.creativeId = '222'
112
+
113
+ const textarea = controller.textareaTarget
114
+ textarea.value = 'hello world'
115
+
116
+ pressEnter(textarea)
117
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
118
+
119
+ // A Turbo Stream / websocket broadcast morphs the comments area while the
120
+ // request is still in flight. Stimulus reconnects the controller, which
121
+ // resets the instance-only `this.sending` flag back to false.
122
+ controller.disconnect()
123
+ controller.connect()
124
+ controller.creativeId = '222'
125
+ controller.currentTopicId = '10073'
126
+
127
+ // The morph preserves the user's typed text; the impatient user hits Enter
128
+ // again because the slow API still has not responded.
129
+ controller.textareaTarget.value = 'hello world'
130
+ pressEnter(controller.textareaTarget)
131
+
132
+ // Durable (module-scope) guard must still block the duplicate.
133
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
134
+ })
135
+
136
+ test('SCENARIO C: after the request completes, the next message sends normally', async () => {
137
+ let resolveFetch
138
+ const fetchSpy = jest.fn(
139
+ () => new Promise((resolve) => { resolveFetch = resolve }),
140
+ )
141
+ global.fetch = fetchSpy
142
+ controller.creativeId = '333'
143
+ // Avoid touching the comment list / DOM rendering on success.
144
+ jest.spyOn(controller, 'resetForm').mockImplementation(() => {})
145
+
146
+ const textarea = controller.textareaTarget
147
+ textarea.value = 'first'
148
+ pressEnter(textarea)
149
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
150
+
151
+ // Resolve the first request, then let the promise microtasks (then/finally) run.
152
+ resolveFetch({ ok: true, status: 200, text: () => Promise.resolve('<div></div>') })
153
+ await new Promise((r) => setTimeout(r, 0))
154
+
155
+ controller.textareaTarget.value = 'second'
156
+ pressEnter(controller.textareaTarget)
157
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
158
+ })
159
+ })
@@ -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
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+ import TopicsController from '../topics_controller'
7
+
8
+ describe('TopicsController#openTopicListPopup', () => {
9
+ let application, controller
10
+
11
+ beforeEach(() => {
12
+ global.requestAnimationFrame = (fn) => { fn(); return 0 }
13
+ document.body.innerHTML = `
14
+ <div id="comments-popup" data-controller="comments--topics"
15
+ data-topic-main-text="All Messages"
16
+ data-topic-search-placeholder-text="Search topics...">
17
+ <div data-comments--topics-target="list"></div>
18
+ </div>
19
+ `
20
+ application = Application.start()
21
+ application.register('comments--topics', TopicsController)
22
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
23
+ controller = application.getControllerForElementAndIdentifier(
24
+ document.getElementById('comments-popup'), 'comments--topics'
25
+ )
26
+ controller.topics = [{ id: 2, name: 'Alpha' }]
27
+ controller.archivedTopics = [{ id: 3, name: 'Zeta' }]
28
+ controller.mainTopicId = null
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ document.body.innerHTML = ''
34
+ application.stop()
35
+ jest.clearAllMocks()
36
+ })
37
+
38
+ test('creates the topic-list modal with the required targets', () => {
39
+ const btn = document.createElement('button')
40
+ document.body.appendChild(btn)
41
+ controller.openTopicListPopup({ currentTarget: btn })
42
+
43
+ const modal = document.getElementById('topic-list-modal')
44
+ expect(modal).not.toBeNull()
45
+ expect(modal.dataset.controller).toBe('topic-list')
46
+ expect(modal.querySelector('[data-topic-list-target="input"]')).not.toBeNull()
47
+ expect(modal.querySelector('[data-topic-list-target="list"]')).not.toBeNull()
48
+ expect(modal.querySelector('[data-topic-list-target="close"]')).not.toBeNull()
49
+ expect(modal.querySelector('input').placeholder).toBe('Search topics...')
50
+ })
51
+
52
+ test('appends the modal inside the chat box so it is caged within it', () => {
53
+ const btn = document.createElement('button')
54
+ document.body.appendChild(btn)
55
+ controller.openTopicListPopup({ currentTarget: btn })
56
+
57
+ const modal = document.getElementById('topic-list-modal')
58
+ // Bounded to the chat popup, not body-level.
59
+ expect(modal.parentElement).toBe(document.getElementById('comments-popup'))
60
+ })
61
+ })