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,879 @@
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
+
18
+ const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }]
19
+ const ARCHIVED = [{ id: 3, name: 'Zeta' }, { id: 4, name: 'Omega' }]
20
+
21
+ describe('TopicsController archived topic messages', () => {
22
+ let application
23
+ let controller
24
+ let changeEvents
25
+
26
+ const render = () => controller.renderTopics(controller.topics, controller.canManageTopics)
27
+
28
+ // Stimulus binds actions on freshly inserted nodes via a MutationObserver, so
29
+ // a click dispatched in the same tick as the render would hit nothing.
30
+ const renderAndBind = async () => {
31
+ render()
32
+ await new Promise((resolve) => setTimeout(resolve, 0))
33
+ }
34
+
35
+ beforeEach(() => {
36
+ document.body.innerHTML = `
37
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
38
+ <div data-comments--topics-target="list"></div>
39
+ </div>
40
+ `
41
+ // jsdom has no layout, so scrollIntoView is undefined on elements.
42
+ Element.prototype.scrollIntoView = jest.fn()
43
+ document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
44
+
45
+ application = Application.start()
46
+ application.register('comments--topics', TopicsController)
47
+
48
+ changeEvents = []
49
+ document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
50
+
51
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
52
+ controller = application.getControllerForElementAndIdentifier(
53
+ document.getElementById('topics'), 'comments--topics'
54
+ )
55
+ controller.creativeIdValue = '42'
56
+ controller.topics = TOPICS
57
+ controller.archivedTopics = ARCHIVED
58
+ controller.mainTopicId = '1'
59
+ controller.canManageTopics = true
60
+ controller.showingArchived = false
61
+ controller.serverLastTopicId = ''
62
+ })
63
+ })
64
+
65
+ afterEach(() => {
66
+ document.body.innerHTML = ''
67
+ document.head.innerHTML = ''
68
+ application.stop()
69
+ jest.clearAllMocks()
70
+ })
71
+
72
+ describe('selecting an archived topic', () => {
73
+ test('renders unread counts on active and archived topic chips', () => {
74
+ controller.topics = [{ id: 1, name: 'Main', unread_count: 2 }, { id: 2, name: 'Alpha', unread_count: 0 }]
75
+ controller.archivedTopics = [{ id: 3, name: 'Zeta', unread_count: 4 }]
76
+ controller.showingArchived = true
77
+
78
+ render()
79
+
80
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="1"] .topic-unread-badge').textContent).toBe('2')
81
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="2"] .topic-unread-badge')).toBeNull()
82
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"] .topic-unread-badge').textContent).toBe('4')
83
+ })
84
+
85
+ test('archived chips carry the select action so clicking one opens it', () => {
86
+ controller.showingArchived = true
87
+ render()
88
+
89
+ const chip = controller.listTarget.querySelector('.topic-archived[data-id="3"]')
90
+ expect(chip.dataset.action).toContain('click->comments--topics#select')
91
+ })
92
+
93
+ test('clicking an archived chip dispatches change with its topic id', async () => {
94
+ controller.showingArchived = true
95
+ await renderAndBind()
96
+
97
+ controller.listTarget.querySelector('.topic-archived[data-id="3"]').click()
98
+
99
+ expect(changeEvents.at(-1).topicId).toBe('3')
100
+ })
101
+
102
+ test('the selected archived chip renders as active', () => {
103
+ controller.showingArchived = true
104
+ controller.serverLastTopicId = '3'
105
+ render()
106
+
107
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
108
+ .toContain('active')
109
+ })
110
+
111
+ test('clicking Restore unarchives without also selecting the topic', async () => {
112
+ const fetchMock = jest.fn().mockResolvedValue({ ok: true })
113
+ global.fetch = fetchMock
114
+ controller.loadTopics = jest.fn()
115
+ controller.showingArchived = true
116
+ await renderAndBind()
117
+
118
+ controller.listTarget.querySelector('.unarchive-topic-btn[data-id="3"]').click()
119
+
120
+ expect(fetchMock).toHaveBeenCalledWith(
121
+ '/creatives/42/topics/3/unarchive',
122
+ expect.objectContaining({ method: 'PATCH' })
123
+ )
124
+ expect(changeEvents).toHaveLength(0)
125
+ })
126
+ })
127
+
128
+ describe('restoreSelection', () => {
129
+ test('keeps an archived topic selected instead of falling back to Main', () => {
130
+ controller.serverLastTopicId = '3'
131
+ render()
132
+
133
+ controller.restoreSelection()
134
+
135
+ expect(changeEvents.at(-1).topicId).toBe('3')
136
+ })
137
+
138
+ test('expands the archived section so the selected topic is visible', () => {
139
+ controller.serverLastTopicId = '3'
140
+ render()
141
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
142
+
143
+ controller.restoreSelection()
144
+
145
+ expect(controller.showingArchived).toBe(true)
146
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).not.toBeNull()
147
+ })
148
+
149
+ test('keeps a live topic selected without expanding the archived section', () => {
150
+ controller.serverLastTopicId = '2'
151
+ render()
152
+
153
+ controller.restoreSelection()
154
+
155
+ expect(changeEvents.at(-1).topicId).toBe('2')
156
+ expect(controller.showingArchived).toBe(false)
157
+ })
158
+
159
+ test('falls back to Main for a topic id that no longer exists', () => {
160
+ controller.serverLastTopicId = '999'
161
+ render()
162
+
163
+ controller.restoreSelection()
164
+
165
+ expect(changeEvents.at(-1).topicId).toBe('1')
166
+ })
167
+
168
+ test('falls back to Main when there are no archived topics at all', () => {
169
+ controller.archivedTopics = []
170
+ controller.serverLastTopicId = '3'
171
+ render()
172
+
173
+ controller.restoreSelection()
174
+
175
+ expect(changeEvents.at(-1).topicId).toBe('1')
176
+ })
177
+ })
178
+
179
+ describe('revealing the archived section', () => {
180
+ test('selecting an archived topic from the topic-list popup expands the section', () => {
181
+ render()
182
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
183
+
184
+ // What openTopicListPopup hands the popup as its pick callback.
185
+ controller.selectTopic('3')
186
+
187
+ expect(controller.showingArchived).toBe(true)
188
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
189
+ .toContain('active')
190
+ })
191
+
192
+ test('selecting a live topic leaves the archived section collapsed', () => {
193
+ render()
194
+
195
+ controller.selectTopic('2')
196
+
197
+ expect(controller.showingArchived).toBe(false)
198
+ })
199
+
200
+ test('collapsing the section stays collapsed while an archived topic is open', () => {
201
+ controller.serverLastTopicId = '3'
202
+ controller.showingArchived = true
203
+ render()
204
+
205
+ controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
206
+
207
+ expect(controller.showingArchived).toBe(false)
208
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
209
+ })
210
+
211
+ test('toggling the section does not reload the message list', () => {
212
+ controller.showingArchived = true
213
+ render()
214
+
215
+ controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
216
+
217
+ expect(changeEvents).toHaveLength(0)
218
+ })
219
+
220
+ describe('after the user collapses the section on the topic they are in', () => {
221
+ const collapse = () => controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
222
+ const rename = () => controller.updateTopicInList({ id: 2, name: 'Alpha renamed' })
223
+
224
+ beforeEach(() => {
225
+ controller.serverLastTopicId = '3'
226
+ controller.showingArchived = true
227
+ render()
228
+ collapse()
229
+ })
230
+
231
+ test('an unrelated rename does not reopen it', () => {
232
+ rename()
233
+
234
+ expect(controller.showingArchived).toBe(false)
235
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
236
+ })
237
+
238
+ test('a reorder does not reopen it', () => {
239
+ controller.reorderTopicsFromServer([2, 1])
240
+
241
+ expect(controller.showingArchived).toBe(false)
242
+ })
243
+
244
+ test('the hidden topic stays selected', () => {
245
+ rename()
246
+
247
+ expect(String(controller.currentTopicId)).toBe('3')
248
+ })
249
+
250
+ test('deliberately reselecting the topic reopens the section', () => {
251
+ controller.selectTopic('3')
252
+
253
+ expect(controller.showingArchived).toBe(true)
254
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
255
+ .toContain('active')
256
+ })
257
+
258
+ test('a rename after that reselection leaves the section open', () => {
259
+ controller.selectTopic('3')
260
+
261
+ rename()
262
+
263
+ expect(controller.showingArchived).toBe(true)
264
+ })
265
+
266
+ test('re-expanding by hand also drops the suppression', () => {
267
+ collapse()
268
+
269
+ rename()
270
+
271
+ expect(controller.showingArchived).toBe(true)
272
+ })
273
+ })
274
+
275
+ test('collapsing while a live topic is open still reveals a restored archived one', () => {
276
+ controller.serverLastTopicId = '2'
277
+ controller.showingArchived = true
278
+ render()
279
+
280
+ controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
281
+ controller.serverLastTopicId = '3'
282
+ controller.restoreSelection()
283
+
284
+ expect(controller.showingArchived).toBe(true)
285
+ })
286
+ })
287
+
288
+ describe('new message badges', () => {
289
+ const newMessage = (topicId) => controller.handleNewMessage({ detail: { topicId } })
290
+
291
+ test('badges the archived toggle when the section is collapsed', () => {
292
+ render()
293
+
294
+ newMessage('3')
295
+
296
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
297
+ .toContain('has-new-messages')
298
+ })
299
+
300
+ test('badges both the chip and the toggle when the section is expanded', () => {
301
+ controller.showingArchived = true
302
+ render()
303
+
304
+ newMessage('3')
305
+
306
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]').classList)
307
+ .toContain('has-new-messages')
308
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
309
+ .toContain('has-new-messages')
310
+ })
311
+
312
+ test('the toggle badge survives a re-render', () => {
313
+ render()
314
+ newMessage('3')
315
+
316
+ render()
317
+
318
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
319
+ .toContain('has-new-messages')
320
+ })
321
+
322
+ test('a live topic does not badge the archived toggle', () => {
323
+ render()
324
+
325
+ newMessage('2')
326
+
327
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
328
+ .not.toContain('has-new-messages')
329
+ })
330
+
331
+ test('the toggle badge stays while another archived topic is still unread', () => {
332
+ render()
333
+ newMessage('3')
334
+ newMessage('4')
335
+
336
+ controller.selectTopic('3')
337
+
338
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
339
+ .toContain('has-new-messages')
340
+ })
341
+
342
+ test('the toggle badge clears once every archived topic has been read', () => {
343
+ render()
344
+ newMessage('3')
345
+ newMessage('4')
346
+
347
+ controller.selectTopic('3')
348
+ controller.selectTopic('4')
349
+
350
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
351
+ .not.toContain('has-new-messages')
352
+ })
353
+
354
+ test('ignores a message for the topic already being viewed', () => {
355
+ controller.serverLastTopicId = '3'
356
+ render()
357
+
358
+ newMessage('3')
359
+
360
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
361
+ .not.toContain('has-new-messages')
362
+ })
363
+ })
364
+
365
+ // The toggle badge is derived from the unread set's size, so any id left in it
366
+ // that no longer belongs to an archived topic lights the toggle with no chip
367
+ // to click and clear it.
368
+ describe('stale unread state', () => {
369
+ const newMessage = (topicId) => controller.handleNewMessage({ detail: { topicId } })
370
+
371
+ // popup_controller._navigateToEntry reuses open()/openForCreative() on this
372
+ // same instance, so no onPopupClosed runs between two creatives.
373
+ const switchCreative = async (creativeId) => {
374
+ controller.subscribe = jest.fn()
375
+ controller.loadTopics = jest.fn().mockResolvedValue(undefined)
376
+ await controller.onPopupOpened({ creativeId })
377
+ }
378
+
379
+ test('switching creatives drops the previous creative unread marks', async () => {
380
+ render()
381
+ newMessage('3')
382
+ expect(controller.archivedWithNewMessages.size).toBe(1)
383
+
384
+ await switchCreative('99')
385
+
386
+ expect(controller.archivedWithNewMessages.size).toBe(0)
387
+ })
388
+
389
+ // popup_controller.handleCreativeClick re-opens the *same* creative when a
390
+ // docked chat receives a workspace-sync event carrying a highlightId. These
391
+ // marks are transient — loadTopics() cannot rebuild them — so following a
392
+ // comment deep link must not wipe the other archived topics' unread state.
393
+ test('re-opening the same creative keeps its unread marks', async () => {
394
+ render()
395
+ newMessage('3')
396
+ newMessage('4')
397
+
398
+ await switchCreative('42')
399
+
400
+ expect([...controller.archivedWithNewMessages].sort()).toEqual(['3', '4'])
401
+ })
402
+
403
+ test('the toggle stays badged after re-opening the same creative', async () => {
404
+ render()
405
+ newMessage('3')
406
+
407
+ await switchCreative('42')
408
+ render()
409
+
410
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
411
+ .toContain('has-new-messages')
412
+ })
413
+
414
+ test("the new creative's archived toggle renders unbadged after a switch", async () => {
415
+ render()
416
+ newMessage('3')
417
+
418
+ await switchCreative('99')
419
+ controller.topics = [{ id: 10, name: 'Main' }]
420
+ controller.archivedTopics = [{ id: 11, name: 'Other' }]
421
+ render()
422
+
423
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
424
+ .not.toContain('has-new-messages')
425
+ })
426
+
427
+ test('loadTopics drops unread marks for topics that are no longer archived', async () => {
428
+ render()
429
+ newMessage('3')
430
+ newMessage('4')
431
+
432
+ // Topic 3 was restored to the live strip; only 4 is still archived.
433
+ global.fetch = jest.fn().mockResolvedValue({
434
+ ok: true,
435
+ status: 200,
436
+ json: async () => ({
437
+ topics: [{ id: 1, name: 'Main' }, { id: 3, name: 'Zeta' }],
438
+ archived_topics: [{ id: 4, name: 'Omega' }],
439
+ can_manage: true,
440
+ main_topic_id: 1,
441
+ }),
442
+ })
443
+
444
+ await controller.loadTopics()
445
+
446
+ expect([...controller.archivedWithNewMessages]).toEqual(['4'])
447
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
448
+ .toContain('has-new-messages')
449
+ })
450
+
451
+ test('the toggle badge clears when the only unread topic is unarchived', () => {
452
+ render()
453
+ newMessage('3')
454
+
455
+ controller.archivedTopics = [{ id: 4, name: 'Omega' }]
456
+ controller.pruneArchivedBadges()
457
+ render()
458
+
459
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
460
+ .not.toContain('has-new-messages')
461
+ })
462
+ })
463
+
464
+ // Archiving the topic in view switches to All Messages, but the preference
465
+ // save is debounced 500ms — so the topics reload it triggers (and the one the
466
+ // "archived" broadcast triggers) still reads the archived topic as
467
+ // last_topic_id. restoreSelection accepts archived ids now, so nothing else
468
+ // stops it from putting the user back where they just left.
469
+ describe('archiving the topic in view', () => {
470
+ // Route by URL: the archive PATCH and the topics reload both go through
471
+ // the same global fetch. lastTopicId is what the server preference still
472
+ // reports at reload time.
473
+ const stubFetch = (lastTopicId) => {
474
+ global.fetch = jest.fn((url) => {
475
+ if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
476
+ return Promise.resolve({
477
+ ok: true,
478
+ status: 200,
479
+ json: async () => ({
480
+ topics: [{ id: 1, name: 'Main' }],
481
+ archived_topics: [{ id: 2, name: 'Alpha' }, ...ARCHIVED],
482
+ can_manage: true,
483
+ main_topic_id: 1,
484
+ last_topic_id: lastTopicId,
485
+ }),
486
+ })
487
+ })
488
+ }
489
+
490
+ // archiveTopic fires loadTopics without awaiting it.
491
+ const archive = async (topicId) => {
492
+ await controller.archiveTopic({
493
+ stopPropagation: jest.fn(),
494
+ currentTarget: { dataset: { id: topicId } },
495
+ })
496
+ await new Promise((resolve) => setTimeout(resolve, 0))
497
+ }
498
+
499
+ test('does not reselect the topic the reload still reports as last_topic_id', async () => {
500
+ controller.serverLastTopicId = '2'
501
+ render()
502
+ stubFetch(2)
503
+
504
+ await archive('2')
505
+
506
+ expect(changeEvents.at(-1).topicId).toBe('1')
507
+ expect(controller.currentTopicId).toBe('1')
508
+ })
509
+
510
+ test('allows All Messages to scroll into view after archiving the selected topic', async () => {
511
+ controller.serverLastTopicId = '2'
512
+ controller._topicScrollInterrupted = true
513
+ const interruptionStatesAtLoad = []
514
+ controller.loadTopics = jest.fn(() => {
515
+ interruptionStatesAtLoad.push(controller._topicScrollInterrupted)
516
+ })
517
+ global.fetch = jest.fn().mockResolvedValue({ ok: true })
518
+
519
+ await controller.archiveTopic({
520
+ stopPropagation: jest.fn(),
521
+ currentTarget: { dataset: { id: '2' } },
522
+ })
523
+
524
+ expect(interruptionStatesAtLoad).toEqual([false])
525
+ })
526
+
527
+ test('preserves the user scroll lock after archiving an inactive topic', async () => {
528
+ controller.serverLastTopicId = '1'
529
+ controller._topicScrollInterrupted = true
530
+ const interruptionStatesAtLoad = []
531
+ controller.loadTopics = jest.fn(() => {
532
+ interruptionStatesAtLoad.push(controller._topicScrollInterrupted)
533
+ })
534
+ global.fetch = jest.fn().mockResolvedValue({ ok: true })
535
+
536
+ await controller.archiveTopic({
537
+ stopPropagation: jest.fn(),
538
+ currentTarget: { dataset: { id: '2' } },
539
+ })
540
+
541
+ expect(interruptionStatesAtLoad).toEqual([true])
542
+ })
543
+
544
+ test('leaves the archived section collapsed instead of revealing the archived topic', async () => {
545
+ controller.serverLastTopicId = '2'
546
+ render()
547
+ stubFetch(2)
548
+
549
+ await archive('2')
550
+
551
+ expect(controller.showingArchived).toBe(false)
552
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="2"]')).toBeNull()
553
+ })
554
+
555
+ test('a deep-link override does not keep reporting the archived topic as current', async () => {
556
+ controller.setOverrideTopicId('2')
557
+ render()
558
+ stubFetch(2)
559
+
560
+ await archive('2')
561
+
562
+ expect(controller.overrideTopicId).toBeUndefined()
563
+ expect(controller.currentTopicId).toBe('1')
564
+ })
565
+
566
+ test('the user can still go back into the topic they just archived', async () => {
567
+ controller.serverLastTopicId = '2'
568
+ render()
569
+ stubFetch(2)
570
+ await archive('2')
571
+
572
+ controller.selectTopic('2')
573
+ controller.restoreSelection()
574
+
575
+ expect(changeEvents.at(-1).topicId).toBe('2')
576
+ expect(controller.showingArchived).toBe(true)
577
+ })
578
+
579
+ test('the guard lifts once the server preference stops naming the topic', async () => {
580
+ controller.serverLastTopicId = '2'
581
+ render()
582
+ stubFetch(2)
583
+ await archive('2')
584
+
585
+ stubFetch(null)
586
+ await controller.loadTopics()
587
+ // Reselected elsewhere — another tab, or a deep link.
588
+ stubFetch(2)
589
+ await controller.loadTopics()
590
+
591
+ expect(changeEvents.at(-1).topicId).toBe('2')
592
+ })
593
+
594
+ // ?topic_id= outranks the saved preference in the currentTopicId getter and
595
+ // survives every reload, so it has to be cleared too — otherwise the guard
596
+ // is the only thing holding the selection back, and it lifts the moment the
597
+ // preference save lands.
598
+ describe('when the URL names the archived topic', () => {
599
+ beforeEach(() => {
600
+ window.history.replaceState({}, '', '/?topic_id=2')
601
+ })
602
+
603
+ afterEach(() => {
604
+ window.history.replaceState({}, '', '/')
605
+ })
606
+
607
+ test('drops the query parameter', async () => {
608
+ render()
609
+ stubFetch(2)
610
+
611
+ await archive('2')
612
+
613
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
614
+ expect(controller.currentTopicId).toBe('1')
615
+ })
616
+
617
+ test('a later reload does not put the user back into it', async () => {
618
+ render()
619
+ stubFetch(2)
620
+ await archive('2')
621
+
622
+ // The preference save has landed by now, so the guard's old lift
623
+ // condition would fire here.
624
+ stubFetch(null)
625
+ await controller.loadTopics()
626
+
627
+ expect(changeEvents.at(-1).topicId).toBe('1')
628
+ expect(controller.showingArchived).toBe(false)
629
+ })
630
+
631
+ test('leaves a query parameter naming a different topic alone', async () => {
632
+ controller.topics = [...TOPICS, { id: 5, name: 'Beta' }]
633
+ render()
634
+ stubFetch(2)
635
+
636
+ await archive('5')
637
+
638
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('2')
639
+ })
640
+ })
641
+
642
+ test('archiving a topic that is not in view leaves the selection alone', async () => {
643
+ controller.topics = [...TOPICS, { id: 5, name: 'Beta' }]
644
+ controller.serverLastTopicId = '2'
645
+ render()
646
+ global.fetch = jest.fn((url) => {
647
+ if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
648
+ return Promise.resolve({
649
+ ok: true,
650
+ status: 200,
651
+ json: async () => ({
652
+ topics: TOPICS,
653
+ archived_topics: [{ id: 5, name: 'Beta' }, ...ARCHIVED],
654
+ can_manage: true,
655
+ main_topic_id: 1,
656
+ last_topic_id: 2,
657
+ }),
658
+ })
659
+ })
660
+
661
+ await archive('5')
662
+
663
+ expect(changeEvents.at(-1).topicId).toBe('2')
664
+ })
665
+ })
666
+
667
+ // The "deleted" broadcast is the one removal path that runs without a
668
+ // loadTopics(), so it is the only thing that can prune the archived cache
669
+ // when an admin deletes an archived topic from another client or the API.
670
+ describe('a deleted broadcast for an archived topic', () => {
671
+ const deleteBroadcast = (topicId) =>
672
+ controller.handleTopicMessage({ action: 'deleted', topic_id: topicId })
673
+
674
+ test('drops it from the archived cache', () => {
675
+ deleteBroadcast(3)
676
+
677
+ expect(controller.archivedTopics.map((t) => t.id)).toEqual([4])
678
+ })
679
+
680
+ test('removes its chip so it can no longer be opened', () => {
681
+ controller.showingArchived = true
682
+ render()
683
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).not.toBeNull()
684
+
685
+ deleteBroadcast(3)
686
+
687
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="3"]')).toBeNull()
688
+ expect(controller.listTarget.querySelector('.topic-archived[data-id="4"]')).not.toBeNull()
689
+ })
690
+
691
+ test('moves the viewer out of it instead of leaving them on a dead conversation', () => {
692
+ controller.serverLastTopicId = '3'
693
+
694
+ deleteBroadcast(3)
695
+
696
+ expect(changeEvents.at(-1).topicId).toBe('1')
697
+ expect(controller.serverLastTopicId).toBe('1')
698
+ })
699
+
700
+ test('clears the toggle badge it was holding', () => {
701
+ render()
702
+ controller.handleNewMessage({ detail: { topicId: '3' } })
703
+ expect(controller.archivedWithNewMessages.has('3')).toBe(true)
704
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
705
+ .toContain('has-new-messages')
706
+
707
+ deleteBroadcast(3)
708
+
709
+ expect(controller.archivedWithNewMessages.has('3')).toBe(false)
710
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
711
+ .not.toContain('has-new-messages')
712
+ })
713
+
714
+ // Another archived topic still unread keeps the toggle lit — the badge
715
+ // aggregates the whole section.
716
+ test('leaves the toggle lit when another archived topic is still unread', () => {
717
+ render()
718
+ controller.handleNewMessage({ detail: { topicId: '3' } })
719
+ controller.handleNewMessage({ detail: { topicId: '4' } })
720
+
721
+ deleteBroadcast(3)
722
+
723
+ expect([...controller.archivedWithNewMessages]).toEqual(['4'])
724
+ expect(controller.listTarget.querySelector('.topic-archived-toggle').classList)
725
+ .toContain('has-new-messages')
726
+ })
727
+
728
+ test('still removes a live topic', () => {
729
+ deleteBroadcast(2)
730
+
731
+ expect(controller.topics.map((t) => t.id)).toEqual([1])
732
+ expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
733
+ })
734
+
735
+ test('does nothing for an id in neither list', () => {
736
+ deleteBroadcast(99)
737
+
738
+ expect(controller.topics.map((t) => t.id)).toEqual([1, 2])
739
+ expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
740
+ expect(changeEvents).toEqual([])
741
+ })
742
+
743
+ // Assigning "" only moves the preference, and both deep-link sources outrank
744
+ // it in the currentTopicId getter, so the deleted id would keep answering
745
+ // for every later restoreSelection().
746
+ describe('while a deep link still names it', () => {
747
+ test('clears an overrideTopicId pointing at it', () => {
748
+ controller.setOverrideTopicId('3')
749
+
750
+ deleteBroadcast(3)
751
+
752
+ expect(controller.currentTopicId).toBe('1')
753
+ })
754
+
755
+ test('drops a ?topic_id= naming it', () => {
756
+ window.history.replaceState({}, '', '/?topic_id=3')
757
+
758
+ deleteBroadcast(3)
759
+
760
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
761
+ expect(controller.currentTopicId).toBe('1')
762
+
763
+ window.history.replaceState({}, '', '/')
764
+ })
765
+
766
+ // The point of clearing them: a re-render must not throw away the topic
767
+ // the user picked after the deletion.
768
+ test('a later re-render keeps the topic the user chose instead', () => {
769
+ controller.setOverrideTopicId('3')
770
+ deleteBroadcast(3)
771
+
772
+ controller.selectTopic('2')
773
+ controller.renderTopics(controller.topics, controller.canManageTopics)
774
+ controller.restoreSelection()
775
+
776
+ expect(controller.currentTopicId).toBe('2')
777
+ expect(changeEvents.at(-1).topicId).toBe('2')
778
+ })
779
+
780
+ test('leaves a deep link pointing at a different topic alone', () => {
781
+ window.history.replaceState({}, '', '/?topic_id=4')
782
+
783
+ deleteBroadcast(3)
784
+
785
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('4')
786
+
787
+ window.history.replaceState({}, '', '/')
788
+ })
789
+ })
790
+ })
791
+
792
+ // list_controller asks isArchivedTopic for every incoming stream, and the
793
+ // reload that follows an archive/unarchive only refreshes the cache when its
794
+ // fetch resolves. Between the two, membership has to already be right.
795
+ describe('archive membership before the reload lands', () => {
796
+ // Hold the topics reload open so every assertion runs inside the window.
797
+ const stubPendingReload = () => {
798
+ global.fetch = jest.fn(() => new Promise(() => {}))
799
+ }
800
+
801
+ const broadcast = (action, topic) =>
802
+ controller.handleTopicMessage({ action, topic })
803
+
804
+ test('an archived broadcast marks the topic archived immediately', () => {
805
+ stubPendingReload()
806
+
807
+ broadcast('archived', { id: 2, name: 'Alpha' })
808
+
809
+ expect(controller.isArchivedTopic('2')).toBe(true)
810
+ })
811
+
812
+ test('an unarchived broadcast unmarks it immediately', () => {
813
+ stubPendingReload()
814
+
815
+ broadcast('unarchived', { id: 3, name: 'Zeta' })
816
+
817
+ expect(controller.isArchivedTopic('3')).toBe(false)
818
+ })
819
+
820
+ test('an unarchived broadcast clears the unread badge it can no longer show', () => {
821
+ stubPendingReload()
822
+ controller.archivedWithNewMessages.add('3')
823
+
824
+ broadcast('unarchived', { id: 3, name: 'Zeta' })
825
+
826
+ expect(controller.archivedWithNewMessages.has('3')).toBe(false)
827
+ })
828
+
829
+ test('an unarchived broadcast keeps another archived topic unread', () => {
830
+ stubPendingReload()
831
+ controller.archivedWithNewMessages.add('3')
832
+ controller.archivedWithNewMessages.add('4')
833
+
834
+ broadcast('unarchived', { id: 3, name: 'Zeta' })
835
+
836
+ expect(controller.archivedWithNewMessages.has('4')).toBe(true)
837
+ })
838
+
839
+ // loadTopics() empties this.topics on its way out, so the archived cache is
840
+ // the only one still readable in this window — and the only one routing
841
+ // consults.
842
+ test('a broadcast with no topic payload leaves the archived cache alone', () => {
843
+ stubPendingReload()
844
+
845
+ controller.handleTopicMessage({ action: 'archived' })
846
+
847
+ expect(controller.archivedTopics.map((t) => t.id)).toEqual([3, 4])
848
+ })
849
+
850
+ // The actor's own reload has the same window as every receiver's.
851
+ test('archiving locally marks the topic archived immediately', async () => {
852
+ global.fetch = jest.fn((url) => {
853
+ if (String(url).includes('/archive')) return Promise.resolve({ ok: true })
854
+ return new Promise(() => {})
855
+ })
856
+
857
+ await controller.archiveTopic({
858
+ stopPropagation: jest.fn(),
859
+ currentTarget: { dataset: { id: '2' } },
860
+ })
861
+
862
+ expect(controller.isArchivedTopic('2')).toBe(true)
863
+ })
864
+
865
+ test('restoring locally unmarks the topic immediately', async () => {
866
+ global.fetch = jest.fn((url) => {
867
+ if (String(url).includes('/unarchive')) return Promise.resolve({ ok: true })
868
+ return new Promise(() => {})
869
+ })
870
+
871
+ await controller.unarchiveTopic({
872
+ stopPropagation: jest.fn(),
873
+ currentTarget: { dataset: { id: '3' } },
874
+ })
875
+
876
+ expect(controller.isArchivedTopic('3')).toBe(false)
877
+ })
878
+ })
879
+ })