collavre 0.24.0 → 0.25.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,467 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ // selectTopic persists the selection through the topics API; stub it so the
8
+ // tests stay offline and the debounce timer has nothing to flush.
9
+ jest.unstable_mockModule('../../../lib/api/topics', () => ({
10
+ fetchNextTopicName: jest.fn(),
11
+ createTopicWithComments: jest.fn(),
12
+ saveLastTopic: jest.fn().mockResolvedValue(undefined),
13
+ }))
14
+
15
+ const { Application } = await import('@hotwired/stimulus')
16
+ const TopicsController = (await import('../topics_controller')).default
17
+ const CommentsListController = (await import('../list_controller')).default
18
+
19
+ const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }]
20
+ const ARCHIVED = [{ id: 3, name: 'Zeta' }]
21
+
22
+ // CommentsController#index answers a deep link (around_comment_id) with the
23
+ // topic the target comment actually lives in.
24
+ const commentsResponse = (topicId) => ({
25
+ ok: true,
26
+ headers: { get: (name) => (name === 'X-Topic-Id' ? topicId : null) },
27
+ text: async () => '',
28
+ })
29
+
30
+ const buildListController = ({ currentTopicId = '', topicsController = null, element = null } = {}) => {
31
+ const controller = Object.create(CommentsListController.prototype)
32
+ controller.creativeId = '42'
33
+ controller.currentTopicId = currentTopicId
34
+ controller.manualSearchQuery = null
35
+
36
+ const list = document.createElement('div')
37
+ Object.defineProperty(controller, 'listTarget', { value: list })
38
+ Object.defineProperty(controller, 'element', { value: element || document.createElement('div') })
39
+ Object.defineProperty(controller, 'popupController', {
40
+ value: topicsController ? { topicsController, updatePosition: () => {} } : null,
41
+ })
42
+ // Both are prototype getters that reach into a Stimulus application; own
43
+ // properties shadow them without standing one up.
44
+ Object.defineProperty(controller, 'formController', { value: null })
45
+
46
+ return controller
47
+ }
48
+
49
+ describe('CommentsListController deep-linked topic resolution', () => {
50
+ beforeEach(() => {
51
+ document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
52
+ })
53
+
54
+ afterEach(() => {
55
+ document.head.innerHTML = ''
56
+ jest.clearAllMocks()
57
+ })
58
+
59
+ test('marks a comment topic-link switch as user initiated', () => {
60
+ const topicsController = { selectTopic: jest.fn() }
61
+ const controller = buildListController({ topicsController })
62
+
63
+ controller.switchToTopic('3')
64
+
65
+ expect(topicsController.selectTopic).toHaveBeenCalledWith('3', { userInitiated: true })
66
+ })
67
+
68
+ test('marks topic creation from the move popup as user initiated', () => {
69
+ const controller = buildListController()
70
+ controller.selection = new Set(['comment-1'])
71
+ controller.clearSelection = jest.fn()
72
+ controller.switchToTopic = jest.fn()
73
+ const topicSearchController = {
74
+ openForCreative: jest.fn((_creativeId, _rect, onSelect) => {
75
+ onSelect({ id: '9', created: true })
76
+ }),
77
+ }
78
+ const modal = document.createElement('div')
79
+ modal.id = 'topic-search-modal'
80
+ document.body.appendChild(modal)
81
+ Object.defineProperty(controller, 'application', {
82
+ value: {
83
+ getControllerForElementAndIdentifier: jest.fn(() => topicSearchController),
84
+ },
85
+ })
86
+
87
+ controller.openTopicSearchPopup({
88
+ currentTarget: { getBoundingClientRect: () => ({}) },
89
+ })
90
+
91
+ expect(controller.clearSelection).toHaveBeenCalledTimes(1)
92
+ expect(controller.switchToTopic).toHaveBeenCalledWith('9')
93
+ })
94
+
95
+ // updateSelectionUI only repaints chips: it neither expands the archived
96
+ // section nor tells form_controller which conversation the reply belongs to.
97
+ test('routes the server-resolved topic through selectTopic', async () => {
98
+ const topicsController = {
99
+ setOverrideTopicId: jest.fn(),
100
+ selectTopic: jest.fn(),
101
+ updateSelectionUI: jest.fn(),
102
+ }
103
+ const controller = buildListController({ currentTopicId: '2', topicsController })
104
+ global.fetch = jest.fn().mockResolvedValue(commentsResponse('3'))
105
+
106
+ await controller.fetchComments({ around_comment_id: 55 })
107
+
108
+ expect(topicsController.setOverrideTopicId).toHaveBeenCalledWith('3')
109
+ expect(topicsController.selectTopic).toHaveBeenCalledWith('3')
110
+ expect(controller.currentTopicId).toBe('3')
111
+ expect(controller.listTarget.dataset.currentTopicId).toBe('3')
112
+ })
113
+
114
+ // loadInitialComments drops responses whose topic no longer matches the one it
115
+ // requested, to survive creative switches. A server-resolved deep link trips
116
+ // that guard with its own answer and the list never leaves "Loading...".
117
+ test('renders a response that switched topic instead of discarding it as stale', async () => {
118
+ const topicsController = { setOverrideTopicId: jest.fn(), selectTopic: jest.fn() }
119
+ const controller = buildListController({ currentTopicId: '2', topicsController })
120
+ controller.selection = new Set()
121
+ controller._loadCommentsVersion = 0
122
+ controller.prevMsgNavigator = { reset: jest.fn() }
123
+ controller.highlightAfterLoad = 55
124
+ controller.highlightComment = jest.fn()
125
+ controller.markCommentsRead = jest.fn()
126
+ controller.scrollToBottom = jest.fn()
127
+ global.fetch = jest.fn().mockResolvedValue({
128
+ ok: true,
129
+ headers: { get: (name) => (name === 'X-Topic-Id' ? '3' : null) },
130
+ text: async () => '<div id="comment_55" class="comment-item">deep linked</div>',
131
+ })
132
+
133
+ controller.loadInitialComments()
134
+ await new Promise((resolve) => setTimeout(resolve, 0))
135
+
136
+ expect(controller.listTarget.innerHTML).toContain('comment_55')
137
+ expect(controller.highlightComment).toHaveBeenCalledWith(55)
138
+ })
139
+
140
+ // The allowance is scoped to the one request the server answered: a newer load
141
+ // must still be able to discard this one.
142
+ test('still discards a response superseded by a newer load', async () => {
143
+ const topicsController = { setOverrideTopicId: jest.fn(), selectTopic: jest.fn() }
144
+ const controller = buildListController({ currentTopicId: '2', topicsController })
145
+ controller.selection = new Set()
146
+ controller._loadCommentsVersion = 0
147
+ controller.prevMsgNavigator = { reset: jest.fn() }
148
+ controller.highlightComment = jest.fn()
149
+ controller.markCommentsRead = jest.fn()
150
+ controller.scrollToBottom = jest.fn()
151
+ global.fetch = jest.fn().mockImplementation(async () => {
152
+ controller._loadCommentsVersion += 1 // a newer load starts mid-flight
153
+ return {
154
+ ok: true,
155
+ headers: { get: (name) => (name === 'X-Topic-Id' ? '3' : null) },
156
+ text: async () => '<div id="comment_55">stale</div>',
157
+ }
158
+ })
159
+
160
+ controller.loadInitialComments()
161
+ await new Promise((resolve) => setTimeout(resolve, 0))
162
+
163
+ expect(controller.listTarget.innerHTML).not.toContain('comment_55')
164
+ })
165
+
166
+ test('leaves the topics controller alone when the server confirms the current topic', async () => {
167
+ const topicsController = {
168
+ setOverrideTopicId: jest.fn(),
169
+ selectTopic: jest.fn(),
170
+ updateSelectionUI: jest.fn(),
171
+ }
172
+ const controller = buildListController({ currentTopicId: '3', topicsController })
173
+ global.fetch = jest.fn().mockResolvedValue(commentsResponse('3'))
174
+
175
+ await controller.fetchComments({ around_comment_id: 55 })
176
+
177
+ expect(topicsController.selectTopic).not.toHaveBeenCalled()
178
+ expect(controller.listTarget.dataset.currentTopicId).toBe('3')
179
+ })
180
+
181
+ // A deep link that loses the race still gets its X-Topic-Id answered. Acting on
182
+ // it would move the selection to a topic nobody is looking at any more, and the
183
+ // load that replaced it would render into that wrong selection.
184
+ const raceDeepLinkAgainstSelection = () => {
185
+ const topicsController = { setOverrideTopicId: jest.fn(), selectTopic: jest.fn() }
186
+ const controller = buildListController({ currentTopicId: '2', topicsController })
187
+ controller.selection = new Set()
188
+ controller._loadCommentsVersion = 0
189
+ controller.prevMsgNavigator = { reset: jest.fn() }
190
+ controller.highlightComment = jest.fn()
191
+ controller.markCommentsRead = jest.fn()
192
+ controller.scrollToBottom = jest.fn()
193
+
194
+ let releaseDeepLink
195
+ let releaseSelected
196
+ const deepLinkGate = new Promise((resolve) => { releaseDeepLink = resolve })
197
+ const selectedGate = new Promise((resolve) => { releaseSelected = resolve })
198
+
199
+ global.fetch = jest.fn()
200
+ .mockImplementationOnce(async () => {
201
+ await deepLinkGate
202
+ return {
203
+ ok: true,
204
+ headers: { get: (name) => (name === 'X-Topic-Id' ? '3' : null) },
205
+ text: async () => '<div id="comment_55" class="comment-item">deep linked</div>',
206
+ }
207
+ })
208
+ .mockImplementationOnce(async () => {
209
+ await selectedGate
210
+ return {
211
+ ok: true,
212
+ headers: { get: () => null },
213
+ text: async () => '<div id="comment_99" class="comment-item">topic nine</div>',
214
+ }
215
+ })
216
+
217
+ controller.highlightAfterLoad = 55
218
+ controller.loadInitialComments()
219
+
220
+ // The user picks another topic before the deep link comes back.
221
+ controller.highlightAfterLoad = null
222
+ controller.currentTopicId = '9'
223
+ controller.loadInitialComments()
224
+
225
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
226
+ return { controller, topicsController, releaseDeepLink, releaseSelected, flush }
227
+ }
228
+
229
+ test('ignores the X-Topic-Id of a load that was already superseded', async () => {
230
+ const { controller, topicsController, releaseDeepLink, flush } = raceDeepLinkAgainstSelection()
231
+
232
+ releaseDeepLink()
233
+ await flush()
234
+
235
+ expect(controller.currentTopicId).toBe('9')
236
+ expect(topicsController.selectTopic).not.toHaveBeenCalled()
237
+ expect(topicsController.setOverrideTopicId).not.toHaveBeenCalled()
238
+ })
239
+
240
+ test('does not retain the All Messages snapshot from a superseded load', async () => {
241
+ const controller = buildListController()
242
+ controller._loadCommentsVersion = 2
243
+ controller.renderedAllTopicIds = ['9']
244
+ controller.renderedAllTopicWatermarks = { 9: 90 }
245
+ global.fetch = jest.fn().mockResolvedValue({
246
+ ok: true,
247
+ headers: {
248
+ get: (name) => ({
249
+ 'X-Rendered-Topic-Ids': '1,2',
250
+ 'X-Rendered-Topic-Watermarks': '{"1":10,"2":20}',
251
+ })[name] || null,
252
+ },
253
+ text: async () => '',
254
+ })
255
+
256
+ await controller.fetchComments({}, { loadVersion: 1 })
257
+
258
+ expect(controller.renderedAllTopicIds).toEqual(['9'])
259
+ expect(controller.renderedAllTopicWatermarks).toEqual({ 9: 90 })
260
+ })
261
+
262
+ test('records whether the All Messages snapshot rendered the legacy lane', async () => {
263
+ const controller = buildListController()
264
+ controller._loadCommentsVersion = 1
265
+ global.fetch = jest.fn().mockResolvedValue({
266
+ ok: true,
267
+ headers: {
268
+ get: (name) => ({
269
+ 'X-Rendered-Topic-Ids': '1',
270
+ 'X-Rendered-Topic-Watermarks': '{"_legacy":10,"1":20}',
271
+ })[name] || null,
272
+ },
273
+ text: async () => '',
274
+ })
275
+
276
+ await controller.fetchComments({}, { loadVersion: 1 })
277
+
278
+ expect(controller.renderedAllIncludesLegacy).toBe(true)
279
+ })
280
+
281
+ test('does not pass the topic-guard exemption to the load that superseded it', async () => {
282
+ const { controller, releaseDeepLink, releaseSelected, flush } = raceDeepLinkAgainstSelection()
283
+
284
+ releaseDeepLink()
285
+ await flush()
286
+ releaseSelected()
287
+ await flush()
288
+
289
+ expect(controller.isServerResolvedTopic(2)).toBe(false)
290
+ // The surviving load rendered its own topic, and the selection it renders
291
+ // under is the one the user picked.
292
+ expect(controller.listTarget.innerHTML).toContain('comment_99')
293
+ expect(controller.listTarget.dataset.currentTopicId).toBe('9')
294
+ })
295
+
296
+ // The exemption is only for a load the server retopiced. A load the server
297
+ // said nothing about is still subject to the plain stale-topic guard, on both
298
+ // the success and the failure path.
299
+ describe('a load the server did not retopic', () => {
300
+ const buildPendingLoad = () => {
301
+ const topicsController = { setOverrideTopicId: jest.fn(), selectTopic: jest.fn() }
302
+ const controller = buildListController({ currentTopicId: '2', topicsController })
303
+ controller.selection = new Set()
304
+ controller._loadCommentsVersion = 0
305
+ controller.prevMsgNavigator = { reset: jest.fn() }
306
+ controller.highlightComment = jest.fn()
307
+ controller.markCommentsRead = jest.fn()
308
+ controller.scrollToBottom = jest.fn()
309
+ return controller
310
+ }
311
+
312
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
313
+
314
+ test('discards its HTML when the topic moved while it was in flight', async () => {
315
+ const controller = buildPendingLoad()
316
+ global.fetch = jest.fn().mockImplementation(async () => {
317
+ controller.currentTopicId = '9' // the user switched mid-flight
318
+ return {
319
+ ok: true,
320
+ headers: { get: () => null },
321
+ text: async () => '<div id="comment_55">topic two</div>',
322
+ }
323
+ })
324
+
325
+ controller.loadInitialComments()
326
+ await flush()
327
+
328
+ expect(controller.listTarget.innerHTML).not.toContain('comment_55')
329
+ })
330
+
331
+ test('discards its error when the topic moved while it was in flight', async () => {
332
+ const controller = buildPendingLoad()
333
+ global.fetch = jest.fn().mockImplementation(async () => {
334
+ controller.currentTopicId = '9'
335
+ return {
336
+ ok: false,
337
+ headers: { get: () => null },
338
+ json: async () => ({ error: 'No permission' }),
339
+ }
340
+ })
341
+
342
+ controller.loadInitialComments()
343
+ await flush()
344
+
345
+ expect(controller.listTarget.innerHTML).toBe('')
346
+ })
347
+
348
+ test('still renders its error when the topic did not move', async () => {
349
+ const controller = buildPendingLoad()
350
+ global.fetch = jest.fn().mockResolvedValue({
351
+ ok: false,
352
+ headers: { get: () => null },
353
+ json: async () => ({ error: 'No permission' }),
354
+ })
355
+
356
+ controller.loadInitialComments()
357
+ await flush()
358
+
359
+ expect(controller.listTarget.innerHTML).toContain('No permission')
360
+ })
361
+ })
362
+ })
363
+
364
+ describe('CommentsListController deep link into an archived topic', () => {
365
+ let application
366
+ let topicsController
367
+ let changeEvents
368
+
369
+ beforeEach(() => {
370
+ document.body.innerHTML = `
371
+ <div id="chat-popup">
372
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
373
+ <div data-comments--topics-target="list"></div>
374
+ </div>
375
+ </div>
376
+ `
377
+ // jsdom has no layout, so scrollIntoView is undefined on elements.
378
+ Element.prototype.scrollIntoView = jest.fn()
379
+ document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
380
+
381
+ application = Application.start()
382
+ application.register('comments--topics', TopicsController)
383
+
384
+ changeEvents = []
385
+ document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
386
+
387
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
388
+ topicsController = application.getControllerForElementAndIdentifier(
389
+ document.getElementById('topics'), 'comments--topics'
390
+ )
391
+ topicsController.creativeIdValue = '42'
392
+ topicsController.topics = TOPICS
393
+ topicsController.archivedTopics = ARCHIVED
394
+ topicsController.mainTopicId = '1'
395
+ topicsController.canManageTopics = true
396
+ topicsController.showingArchived = false
397
+ topicsController.serverLastTopicId = ''
398
+ topicsController.renderTopics(TOPICS, true)
399
+ })
400
+ })
401
+
402
+ afterEach(() => {
403
+ document.body.innerHTML = ''
404
+ document.head.innerHTML = ''
405
+ application.stop()
406
+ jest.clearAllMocks()
407
+ })
408
+
409
+ const deepLinkTo = async (topicId, { currentTopicId = '2' } = {}) => {
410
+ const controller = buildListController({
411
+ currentTopicId,
412
+ topicsController,
413
+ element: document.getElementById('chat-popup'),
414
+ })
415
+ global.fetch = jest.fn().mockResolvedValue(commentsResponse(topicId))
416
+ await controller.fetchComments({ around_comment_id: 55 })
417
+ return controller
418
+ }
419
+
420
+ // A collapsed archived section renders no chips, so the resolved topic would
421
+ // be open with nothing in the strip showing which one it is.
422
+ test('expands the archived section and marks the chip active', async () => {
423
+ await deepLinkTo('3')
424
+
425
+ expect(topicsController.showingArchived).toBe(true)
426
+ const chip = topicsController.listTarget.querySelector('.topic-archived[data-id="3"]')
427
+ expect(chip).not.toBeNull()
428
+ expect(chip.classList.contains('active')).toBe(true)
429
+ })
430
+
431
+ // form_controller reads the active topic only from this event; without it a
432
+ // reply typed under the deep link posts into the previously selected topic.
433
+ test('dispatches change so the form follows the resolved topic', async () => {
434
+ await deepLinkTo('3')
435
+
436
+ expect(changeEvents.at(-1).topicId).toBe('3')
437
+ })
438
+
439
+ test('follows a deep link into a live topic without expanding the archived section', async () => {
440
+ await deepLinkTo('2', { currentTopicId: '' })
441
+
442
+ expect(changeEvents.at(-1).topicId).toBe('2')
443
+ expect(topicsController.showingArchived).toBe(false)
444
+ })
445
+
446
+ // The change event reaches list_controller's own handler; it must not restart
447
+ // the load, which would throw away the comment the deep link is scrolling to.
448
+ test('keeps the highlight window instead of reloading from latest', async () => {
449
+ const controller = buildListController({
450
+ currentTopicId: '2',
451
+ topicsController,
452
+ element: document.getElementById('chat-popup'),
453
+ })
454
+ controller.handleTopicChange = CommentsListController.prototype.handleTopicChange.bind(controller)
455
+ controller.element.addEventListener('comments--topics:change', controller.handleTopicChange)
456
+ controller.highlightAfterLoad = 55
457
+ controller.highlightCreativeId = '42'
458
+ controller.resetToLatest = jest.fn()
459
+
460
+ global.fetch = jest.fn().mockResolvedValue(commentsResponse('3'))
461
+ await controller.fetchComments({ around_comment_id: 55 })
462
+
463
+ expect(controller.resetToLatest).not.toHaveBeenCalled()
464
+ expect(controller.highlightAfterLoad).toBe(55)
465
+ expect(controller.highlightCreativeId).toBe('42')
466
+ })
467
+ })
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import CommentsListController from '../list_controller'
7
+
8
+ describe('CommentsListController docked startup', () => {
9
+ test('ignores a repeated topic selection while a deep link is loading', () => {
10
+ const controller = Object.create(CommentsListController.prototype)
11
+ controller.currentTopicId = '12'
12
+ controller.highlightAfterLoad = '34'
13
+ controller.initialLoadComplete = false
14
+ controller.resetToLatest = jest.fn()
15
+
16
+ controller.handleTopicChange({ detail: { topicId: '12' } })
17
+
18
+ expect(controller.resetToLatest).not.toHaveBeenCalled()
19
+ expect(controller.highlightAfterLoad).toBe('34')
20
+ })
21
+
22
+ test('loads the newly selected topic when it supersedes a pending deep link', () => {
23
+ const controller = Object.create(CommentsListController.prototype)
24
+ controller.currentTopicId = '12'
25
+ controller.highlightAfterLoad = '34'
26
+ controller.initialLoadComplete = false
27
+ controller.resetToLatest = jest.fn()
28
+
29
+ controller.handleTopicChange({ detail: { topicId: '13' } })
30
+
31
+ expect(controller.currentTopicId).toBe('13')
32
+ expect(controller.highlightAfterLoad).toBeNull()
33
+ expect(controller.resetToLatest).toHaveBeenCalledTimes(1)
34
+ })
35
+
36
+ test('suppresses only the topic event emitted during popup initialization', () => {
37
+ const controller = Object.create(CommentsListController.prototype)
38
+ controller.currentTopicId = '12'
39
+ controller.highlightAfterLoad = '34'
40
+ controller.initialLoadComplete = false
41
+ controller.suppressTopicChangeLoad = true
42
+ controller.resetToLatest = jest.fn()
43
+
44
+ controller.handleTopicChange({ detail: { topicId: '13' } })
45
+
46
+ expect(controller.currentTopicId).toBe('13')
47
+ expect(controller.highlightAfterLoad).toBe('34')
48
+ expect(controller.resetToLatest).not.toHaveBeenCalled()
49
+ })
50
+
51
+ test('does not carry a pending highlight into a different creative', () => {
52
+ const controller = Object.create(CommentsListController.prototype)
53
+ controller.creativeId = '12'
54
+ controller.highlightAfterLoad = '34'
55
+ controller.highlightCreativeId = '12'
56
+ controller.deepLinkCommentId = null
57
+ controller.resetState = jest.fn()
58
+ controller.loadInitialComments = jest.fn()
59
+ controller.listTarget = { innerHTML: '' }
60
+ Object.defineProperty(controller, 'element', { value: { dataset: { loadingText: 'Loading' } } })
61
+ Object.defineProperty(controller, 'presenceController', { value: null })
62
+
63
+ // Popup initialization pre-sets creativeId before this method runs, so the
64
+ // highlight must be tied to its original creative rather than that field.
65
+ controller.creativeId = '13'
66
+ controller.onPopupOpened({ creativeId: '13' })
67
+
68
+ expect(controller.highlightAfterLoad).toBeNull()
69
+ expect(controller.loadInitialComments).toHaveBeenCalledTimes(1)
70
+ })
71
+
72
+ test('ignores a rejected comments request after the popup closes', async () => {
73
+ const controller = Object.create(CommentsListController.prototype)
74
+ let rejectRequest
75
+ controller.creativeId = '12'
76
+ controller.currentTopicId = ''
77
+ controller.highlightAfterLoad = null
78
+ controller._loadCommentsVersion = 0
79
+ controller.selection = new Set()
80
+ controller.prevMsgNavigator = { reset: jest.fn() }
81
+ controller.fetchComments = jest.fn(() => new Promise((_resolve, reject) => { rejectRequest = reject }))
82
+ controller.resetState = jest.fn()
83
+ controller.listTarget = { innerHTML: 'Loading' }
84
+
85
+ controller.loadInitialComments()
86
+ controller.onPopupClosed()
87
+ rejectRequest(new Error('stale failure'))
88
+ await Promise.resolve()
89
+ await Promise.resolve()
90
+
91
+ expect(controller.listTarget.innerHTML).toBe('')
92
+ })
93
+
94
+ test('flushes a pending read safely when the CSRF meta tag is absent', () => {
95
+ const controller = Object.create(CommentsListController.prototype)
96
+ const element = document.createElement('div')
97
+ document.body.appendChild(element)
98
+ controller.creativeId = '12'
99
+ controller.currentTopicId = ''
100
+ controller.pendingRead = { creativeId: '12', topicId: null, topicIds: null, topicWatermarks: null }
101
+ Object.defineProperty(controller, 'element', { value: element })
102
+ const originalFetch = global.fetch
103
+ global.fetch = jest.fn().mockResolvedValue({ ok: true, headers: new Headers() })
104
+ document.querySelector('meta[name="csrf-token"]')?.remove()
105
+
106
+ expect(() => controller.flushPendingRead()).not.toThrow()
107
+ expect(global.fetch).toHaveBeenCalledWith(
108
+ '/comment_read_pointers/update',
109
+ expect.objectContaining({ method: 'POST' })
110
+ )
111
+
112
+ global.fetch = originalFetch
113
+ element.remove()
114
+ })
115
+ })