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,981 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const saveLastTopic = jest.fn().mockResolvedValue(true)
8
+
9
+ jest.unstable_mockModule('../../../lib/api/topics', () => ({
10
+ fetchNextTopicName: jest.fn(),
11
+ createTopicWithComments: jest.fn(),
12
+ saveLastTopic,
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' }, { id: 3, name: 'Beta' }]
19
+
20
+ describe('TopicsController selection vs. in-flight loadTopics', () => {
21
+ let application
22
+ let controller
23
+ let changeEvents
24
+
25
+ beforeEach(() => {
26
+ document.body.innerHTML = `
27
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
28
+ <div data-comments--topics-target="list"></div>
29
+ </div>
30
+ `
31
+ Element.prototype.scrollIntoView = jest.fn()
32
+ document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
33
+
34
+ application = Application.start()
35
+ application.register('comments--topics', TopicsController)
36
+
37
+ changeEvents = []
38
+ document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
39
+
40
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
41
+ controller = application.getControllerForElementAndIdentifier(
42
+ document.getElementById('topics'), 'comments--topics'
43
+ )
44
+ controller.creativeIdValue = '42'
45
+ controller.topics = TOPICS
46
+ controller.archivedTopics = []
47
+ controller.mainTopicId = '1'
48
+ controller.canManageTopics = true
49
+ controller.showingArchived = false
50
+ controller.serverLastTopicId = '2'
51
+ // The strip is on screen before any of these races start — that is what
52
+ // makes a chip clickable, and it is what marks the pick as being about
53
+ // creative 42.
54
+ controller.renderTopics(TOPICS, true)
55
+ })
56
+ })
57
+
58
+ afterEach(() => {
59
+ controller?.cancelPendingSaveLastTopic()
60
+ document.body.innerHTML = ''
61
+ document.head.innerHTML = ''
62
+ application.stop()
63
+ jest.clearAllMocks()
64
+ delete global.fetch
65
+ window.history.replaceState({}, '', '/')
66
+ })
67
+
68
+ // The user picked a topic while the strip was still fetching. The server's
69
+ // last_topic_id still names the topic they left, because the save for the new
70
+ // pick is debounced and has not landed. Honouring it throws the click away.
71
+ test('a topic picked while the strip is loading survives the response', async () => {
72
+ let resolveFetch
73
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
74
+
75
+ const loading = controller.loadTopics()
76
+
77
+ // The user clicks Beta while the fetch is in flight.
78
+ controller.selectTopic('3')
79
+ expect(controller.currentTopicId).toBe('3')
80
+
81
+ resolveFetch({
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({
85
+ topics: TOPICS,
86
+ archived_topics: [],
87
+ can_manage: true,
88
+ main_topic_id: 1,
89
+ last_topic_id: 2,
90
+ }),
91
+ })
92
+ await loading
93
+
94
+ expect(controller.currentTopicId).toBe('3')
95
+ expect(changeEvents.at(-1).topicId).toBe('3')
96
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
97
+ .toContain('active')
98
+ })
99
+
100
+ // Same race, one step earlier: the pick has to survive the assignment of the
101
+ // server's answer to serverLastTopicId, not just restoreSelection().
102
+ test('the stale server last_topic_id does not overwrite the pick', async () => {
103
+ let resolveFetch
104
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
105
+
106
+ const loading = controller.loadTopics()
107
+ controller.selectTopic('3')
108
+
109
+ resolveFetch({
110
+ ok: true,
111
+ status: 200,
112
+ json: async () => ({
113
+ topics: TOPICS,
114
+ archived_topics: [],
115
+ can_manage: true,
116
+ main_topic_id: 1,
117
+ last_topic_id: 2,
118
+ }),
119
+ })
120
+ await loading
121
+
122
+ expect(controller.serverLastTopicId).toBe('3')
123
+ })
124
+
125
+ // The strip can also be stale about its own creative: another member deletes
126
+ // a topic while the chip for it is still on screen. The pick is about this
127
+ // creative, but it names a topic that no longer exists, so keeping it would
128
+ // fail the lookup in restoreSelection() and persist Main over the preference
129
+ // the server actually holds.
130
+ test('a pick naming a topic the response no longer lists yields to the answer', async () => {
131
+ let resolveFetch
132
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
133
+
134
+ const loading = controller.loadTopics()
135
+ controller.selectTopic('3')
136
+
137
+ resolveFetch({
138
+ ok: true,
139
+ status: 200,
140
+ json: async () => ({
141
+ topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
142
+ archived_topics: [],
143
+ can_manage: true,
144
+ main_topic_id: 1,
145
+ last_topic_id: 2,
146
+ }),
147
+ })
148
+ await loading
149
+
150
+ expect(controller.currentTopicId).toBe('2')
151
+ })
152
+
153
+ // All Messages carries no topic id, so nothing about it can be read off the
154
+ // saved preference — but it is still a pick, and a landing load must not undo
155
+ // it any more than it may undo a chip click.
156
+ describe('an All Messages pick while the strip is loading', () => {
157
+ const respond = (resolveFetch) => resolveFetch({
158
+ ok: true,
159
+ status: 200,
160
+ json: async () => ({
161
+ topics: TOPICS,
162
+ archived_topics: [],
163
+ can_manage: true,
164
+ main_topic_id: 1,
165
+ last_topic_id: 2,
166
+ }),
167
+ })
168
+
169
+ test('survives the response', async () => {
170
+ let resolveFetch
171
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
172
+
173
+ const loading = controller.loadTopics()
174
+ controller.selectTopic('')
175
+
176
+ respond(resolveFetch)
177
+ await loading
178
+
179
+ expect(controller.currentTopicId).toBe('')
180
+ expect(controller.listTarget.querySelector('.topic-all-messages').classList)
181
+ .toContain('active')
182
+ })
183
+
184
+ // The revert this PR is about is the message list snapping back, so the
185
+ // change event matters as much as the strip: nothing may re-announce the
186
+ // topic the user just left.
187
+ test('does not re-announce the topic the user left', async () => {
188
+ let resolveFetch
189
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
190
+
191
+ const loading = controller.loadTopics()
192
+ controller.selectTopic('')
193
+
194
+ respond(resolveFetch)
195
+ await loading
196
+
197
+ expect(changeEvents.at(-1).topicId).toBe('')
198
+ })
199
+
200
+ // ...and the debounced save must still record the pick, not the stale
201
+ // answer that overtook it.
202
+ test('persists All Messages, not the stale answer', async () => {
203
+ let resolveFetch
204
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
205
+
206
+ const loading = controller.loadTopics()
207
+ controller.selectTopic('')
208
+
209
+ respond(resolveFetch)
210
+ await loading
211
+ await controller.flushSaveLastTopic(controller.currentTopicId)
212
+
213
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', null, expect.any(String), expect.anything())
214
+ })
215
+ })
216
+
217
+ // The legacy per-browser preference is adopted on the first load that finds
218
+ // the server holding nothing. A winning empty pick leaves the server value
219
+ // empty on purpose, which reads exactly the same to the migration — so it
220
+ // would hand the user back the topic they just left, by another route.
221
+ describe('a winning empty pick against the localStorage migration', () => {
222
+ const LEGACY_KEY = 'collavre_creative_42_last_topic'
223
+
224
+ const respond = (resolveFetch, lastTopicId) => resolveFetch({
225
+ ok: true,
226
+ status: 200,
227
+ json: async () => ({
228
+ topics: TOPICS,
229
+ archived_topics: [],
230
+ can_manage: true,
231
+ main_topic_id: 1,
232
+ last_topic_id: lastTopicId,
233
+ }),
234
+ })
235
+
236
+ afterEach(() => localStorage.clear())
237
+
238
+ test('the migration does not restore the legacy topic over the pick', async () => {
239
+ localStorage.setItem(LEGACY_KEY, '2')
240
+ let resolveFetch
241
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
242
+
243
+ const loading = controller.loadTopics()
244
+ controller.selectTopic('')
245
+
246
+ respond(resolveFetch, 2)
247
+ await loading
248
+
249
+ expect(controller.currentTopicId).toBe('')
250
+ expect(controller.listTarget.querySelector('.topic-all-messages').classList)
251
+ .toContain('active')
252
+ })
253
+
254
+ test('the legacy topic is not persisted as the preference either', async () => {
255
+ localStorage.setItem(LEGACY_KEY, '2')
256
+ let resolveFetch
257
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
258
+
259
+ const loading = controller.loadTopics()
260
+ controller.selectTopic('')
261
+
262
+ respond(resolveFetch, null)
263
+ await loading
264
+ await controller.flushSaveLastTopic(controller.currentTopicId)
265
+
266
+ expect(saveLastTopic).not.toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
267
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', null, expect.any(String), expect.anything())
268
+ })
269
+
270
+ // The pick supersedes the legacy value, so the key has served its purpose
271
+ // and must not be left behind to re-apply on the next load.
272
+ test('the legacy key is still cleared', async () => {
273
+ localStorage.setItem(LEGACY_KEY, '2')
274
+ let resolveFetch
275
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
276
+
277
+ const loading = controller.loadTopics()
278
+ controller.selectTopic('')
279
+
280
+ respond(resolveFetch, null)
281
+ await loading
282
+
283
+ expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
284
+ })
285
+
286
+ // ...and the migration itself is untouched when no pick is racing it.
287
+ test('an unraced load still migrates the legacy topic', async () => {
288
+ localStorage.setItem(LEGACY_KEY, '2')
289
+ controller.serverLastTopicId = ''
290
+ global.fetch = jest.fn().mockResolvedValue({
291
+ ok: true,
292
+ status: 200,
293
+ json: async () => ({
294
+ topics: TOPICS,
295
+ archived_topics: [],
296
+ can_manage: true,
297
+ main_topic_id: 1,
298
+ last_topic_id: null,
299
+ }),
300
+ })
301
+
302
+ await controller.loadTopics()
303
+
304
+ expect(controller.currentTopicId).toBe('2')
305
+ expect(saveLastTopic).toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
306
+ expect(localStorage.getItem(LEGACY_KEY)).toBeNull()
307
+ })
308
+ })
309
+
310
+ // A pick can only outrank the response if it was made against the strip of
311
+ // the creative the response describes. Switching creatives leaves the
312
+ // previous creative's chips on screen until the new strip lands, so a click
313
+ // in that window is not intent about the creative being loaded.
314
+ describe('a pick against the previous creative\'s strip', () => {
315
+ const OTHER_TOPICS = [{ id: 10, name: 'Main' }, { id: 11, name: 'Gamma' }]
316
+
317
+ const switchTo = (creativeId) => {
318
+ controller.subscribe = jest.fn()
319
+ return controller.onPopupOpened({ creativeId })
320
+ }
321
+
322
+ const otherCreativeResponse = {
323
+ ok: true,
324
+ status: 200,
325
+ json: async () => ({
326
+ topics: OTHER_TOPICS,
327
+ archived_topics: [],
328
+ can_manage: true,
329
+ main_topic_id: 10,
330
+ last_topic_id: 11,
331
+ }),
332
+ }
333
+
334
+ test('does not discard the new creative\'s last_topic_id', async () => {
335
+ let resolveFetch
336
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
337
+
338
+ const switching = switchTo('99')
339
+ // Beta belongs to creative 42; its chip is still rendered.
340
+ controller.selectTopic('3')
341
+
342
+ resolveFetch(otherCreativeResponse)
343
+ await switching
344
+
345
+ expect(controller.serverLastTopicId).toBe('11')
346
+ expect(controller.currentTopicId).toBe('11')
347
+ })
348
+
349
+ test('does not persist Main as the new creative\'s preference', async () => {
350
+ let resolveFetch
351
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
352
+
353
+ const switching = switchTo('99')
354
+ controller.selectTopic('3')
355
+
356
+ resolveFetch(otherCreativeResponse)
357
+ await switching
358
+ await controller.flushSaveLastTopic(controller.currentTopicId)
359
+
360
+ expect(saveLastTopic).toHaveBeenLastCalledWith('99', '11', expect.any(String), expect.anything())
361
+ })
362
+
363
+ // An empty pick is authoritative for the creative whose strip it was made
364
+ // against, and this one was made against the previous creative's — so the
365
+ // creative being opened still resolves to its own saved topic.
366
+ test('an empty pick does not suppress the response either', async () => {
367
+ let resolveFetch
368
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
369
+
370
+ const switching = switchTo('99')
371
+ controller.selectTopic('')
372
+
373
+ resolveFetch(otherCreativeResponse)
374
+ await switching
375
+
376
+ expect(controller.currentTopicId).toBe('11')
377
+ })
378
+
379
+ test('toggling stale archived topics does not make their strip look current', async () => {
380
+ controller.archivedTopics = [{ id: 4, name: 'Archived Alpha' }]
381
+ controller.renderTopics(TOPICS, true)
382
+ let resolveFetch
383
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
384
+
385
+ const switching = switchTo('99')
386
+ controller.toggleArchivedTopics({ stopPropagation: jest.fn() })
387
+ controller.selectTopic('')
388
+
389
+ resolveFetch(otherCreativeResponse)
390
+ await switching
391
+
392
+ expect(controller.serverLastTopicId).toBe('11')
393
+ expect(controller.currentTopicId).toBe('11')
394
+ })
395
+
396
+ test('attributes a new-creative broadcast to the current creative', async () => {
397
+ const NEW_OTHER_TOPIC = { id: 12, name: 'Delta' }
398
+ let resolveFetch
399
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
400
+
401
+ const switching = switchTo('99')
402
+ controller.handleTopicMessage({ action: 'created', topic: NEW_OTHER_TOPIC, user_id: '88' })
403
+ controller.selectTopic('12')
404
+
405
+ resolveFetch({
406
+ ok: true,
407
+ status: 200,
408
+ json: async () => ({
409
+ topics: [...OTHER_TOPICS, NEW_OTHER_TOPIC],
410
+ archived_topics: [],
411
+ can_manage: true,
412
+ main_topic_id: 10,
413
+ last_topic_id: 11,
414
+ }),
415
+ })
416
+ await switching
417
+
418
+ expect(controller._pickCreativeId).toBe('99')
419
+ expect(controller.currentTopicId).toBe('12')
420
+ })
421
+ })
422
+
423
+ // Not every write to the selection is a pick. loadTopics() empties the strip
424
+ // for the duration of its fetch, so any re-render landing in that window
425
+ // restores against an empty topic list and falls back to Main. That fallback
426
+ // is derived from state the load is about to replace — treating it as intent
427
+ // newer than the answer suppresses the very value it was derived from.
428
+ describe('a programmatic restore while the strip is loading', () => {
429
+ const NEW_TOPIC = { id: 4, name: 'Delta' }
430
+
431
+ const respond = (resolveFetch, topics = TOPICS) => resolveFetch({
432
+ ok: true,
433
+ status: 200,
434
+ json: async () => ({
435
+ topics,
436
+ archived_topics: [],
437
+ can_manage: true,
438
+ main_topic_id: 1,
439
+ last_topic_id: 2,
440
+ }),
441
+ })
442
+
443
+ // Another member creates a topic mid-load. handleTopicMessage() renders it
444
+ // on its own — this.topics was emptied — and restoreSelection() cannot find
445
+ // the saved topic among the one topic on screen.
446
+ const broadcastCreate = (userId = '99') => controller.handleTopicMessage({
447
+ action: 'created', topic: NEW_TOPIC, user_id: userId,
448
+ })
449
+
450
+ afterEach(() => { delete document.body.dataset.currentUserId })
451
+
452
+ test('does not suppress the response it was derived from', async () => {
453
+ let resolveFetch
454
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
455
+
456
+ const loading = controller.loadTopics()
457
+ broadcastCreate()
458
+ expect(controller.currentTopicId).toBe('1') // fell back to Main
459
+
460
+ respond(resolveFetch)
461
+ await loading
462
+
463
+ expect(controller.currentTopicId).toBe('2')
464
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
465
+ .toContain('active')
466
+ })
467
+
468
+ test('does not persist Main over the saved preference', async () => {
469
+ let resolveFetch
470
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
471
+
472
+ const loading = controller.loadTopics()
473
+ broadcastCreate()
474
+
475
+ respond(resolveFetch)
476
+ await loading
477
+ await controller.flushSaveLastTopic(controller.currentTopicId)
478
+
479
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
480
+ })
481
+
482
+ // A real pick has already won this load. A later restore is derived from
483
+ // the interim one-topic strip, so it may fall back to Main, but it must not
484
+ // replace the id associated with that authoritative pick.
485
+ test('retains a picked topic across an interim restore', async () => {
486
+ let resolveFetch
487
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
488
+
489
+ const loading = controller.loadTopics()
490
+ controller.selectTopic('3')
491
+ broadcastCreate()
492
+ expect(controller.currentTopicId).toBe('1')
493
+
494
+ respond(resolveFetch)
495
+ await loading
496
+
497
+ expect(controller.currentTopicId).toBe('3')
498
+ await controller.flushSaveLastTopic(controller.currentTopicId)
499
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
500
+ })
501
+
502
+ test('re-dispatches a picked All Messages after an interim restore', async () => {
503
+ let resolveFetch
504
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
505
+
506
+ const loading = controller.loadTopics()
507
+ controller.selectTopic('')
508
+ broadcastCreate()
509
+ expect(changeEvents.at(-1).topicId).toBe('1')
510
+
511
+ respond(resolveFetch)
512
+ await loading
513
+
514
+ expect(controller.currentTopicId).toBe('')
515
+ expect(changeEvents.at(-1).topicId).toBe('')
516
+ expect(controller.listTarget.querySelector('.topic-tag[data-id=""]').classList)
517
+ .toContain('active')
518
+ })
519
+
520
+ // The fallback is not the user moving off the link either, so it must not
521
+ // consume the sources that outrank the preference. They are one-shot: once
522
+ // released, not even a reload gets the linked conversation back.
523
+ test('does not consume a deep-link override', async () => {
524
+ let resolveFetch
525
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
526
+ controller.setOverrideTopicId('3')
527
+
528
+ const loading = controller.loadTopics()
529
+ broadcastCreate()
530
+
531
+ respond(resolveFetch)
532
+ await loading
533
+
534
+ expect(controller.overrideTopicId).toBe('3')
535
+ expect(controller.currentTopicId).toBe('3')
536
+ })
537
+
538
+ test('does not strip ?topic_id=', async () => {
539
+ let resolveFetch
540
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
541
+ window.history.replaceState({}, '', '/creatives/42?topic_id=3')
542
+
543
+ const loading = controller.loadTopics()
544
+ broadcastCreate()
545
+
546
+ respond(resolveFetch)
547
+ await loading
548
+
549
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
550
+ expect(controller.currentTopicId).toBe('3')
551
+ })
552
+
553
+ // The fallback is not the only restore that can land mid-load. archivedTopics
554
+ // outlives the emptied strip, so a preference naming an archived topic is
555
+ // still found — and re-selecting it is no more a pick than falling back to
556
+ // Main is. Left as one, it discards a preference another session moved and
557
+ // writes the local value back over it.
558
+ test('re-selecting the topic already held does not suppress the response', async () => {
559
+ let resolveFetch
560
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
561
+ controller.archivedTopics = [{ id: 5, name: 'Archived' }]
562
+ controller.serverLastTopicId = '5'
563
+
564
+ const loading = controller.loadTopics()
565
+ broadcastCreate()
566
+ expect(controller.currentTopicId).toBe('5') // found among the archived
567
+
568
+ resolveFetch({
569
+ ok: true,
570
+ status: 200,
571
+ json: async () => ({
572
+ topics: TOPICS,
573
+ archived_topics: [{ id: 5, name: 'Archived' }],
574
+ can_manage: true,
575
+ main_topic_id: 1,
576
+ last_topic_id: 2,
577
+ }),
578
+ })
579
+ await loading
580
+ await controller.flushSaveLastTopic(controller.currentTopicId)
581
+
582
+ expect(controller.currentTopicId).toBe('2')
583
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
584
+ })
585
+
586
+ // The same broadcast for a topic this user created elsewhere auto-selects
587
+ // it, and that is intent — it still has to outrank the landing answer.
588
+ test('the auto-select of the user\'s own new topic still outranks the response', async () => {
589
+ let resolveFetch
590
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveFetch = resolve }))
591
+ document.body.dataset.currentUserId = '7'
592
+
593
+ const loading = controller.loadTopics()
594
+ broadcastCreate('7')
595
+
596
+ respond(resolveFetch, [...TOPICS, NEW_TOPIC])
597
+ await loading
598
+
599
+ expect(controller.currentTopicId).toBe('4')
600
+ })
601
+ })
602
+
603
+ // A subsequent reload can begin before the debounce lands — archive state
604
+ // broadcasts do exactly that. Its answer still predates the unsaved pick,
605
+ // even though the pick happened before this particular request began.
606
+ test('an unsaved pick survives a load started after the pick', async () => {
607
+ controller.selectTopic('3')
608
+
609
+ global.fetch = jest.fn().mockResolvedValue({
610
+ ok: true,
611
+ status: 200,
612
+ json: async () => ({
613
+ topics: TOPICS,
614
+ archived_topics: [],
615
+ can_manage: true,
616
+ main_topic_id: 1,
617
+ last_topic_id: 2,
618
+ }),
619
+ })
620
+
621
+ await controller.loadTopics()
622
+
623
+ expect(controller.currentTopicId).toBe('3')
624
+ await controller.flushSaveLastTopic(controller.currentTopicId)
625
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
626
+ })
627
+
628
+ // Once that pick reaches the server, a later load is free to take the
629
+ // server's value again. The pending marker must not become a permanent
630
+ // preference override.
631
+ test('a saved pick yields to a later load', async () => {
632
+ controller.selectTopic('3')
633
+ await controller.flushSaveLastTopic('3')
634
+
635
+ global.fetch = jest.fn().mockResolvedValue({
636
+ ok: true,
637
+ status: 200,
638
+ json: async () => ({
639
+ topics: TOPICS,
640
+ archived_topics: [],
641
+ can_manage: true,
642
+ main_topic_id: 1,
643
+ last_topic_id: 2,
644
+ }),
645
+ })
646
+
647
+ await controller.loadTopics()
648
+
649
+ expect(controller.currentTopicId).toBe('2')
650
+ })
651
+
652
+ test('a failed save keeps the pick authoritative for a later load', async () => {
653
+ controller.selectTopic('3')
654
+ saveLastTopic.mockResolvedValueOnce(false)
655
+ await controller.flushSaveLastTopic('3')
656
+
657
+ global.fetch = jest.fn().mockResolvedValue({
658
+ ok: true,
659
+ status: 200,
660
+ json: async () => ({
661
+ topics: TOPICS,
662
+ archived_topics: [],
663
+ can_manage: true,
664
+ main_topic_id: 1,
665
+ last_topic_id: 2,
666
+ }),
667
+ })
668
+
669
+ await controller.loadTopics()
670
+
671
+ expect(controller.currentTopicId).toBe('3')
672
+ })
673
+ })
674
+
675
+ // The deep-link override and ?topic_id= both outrank the saved preference in
676
+ // the getter, so a pick that only writes the preference never becomes the
677
+ // answer — the next render lights the old chip and the next restore navigates
678
+ // back to it.
679
+ describe('TopicsController selection vs. higher-priority selection sources', () => {
680
+ let application
681
+ let controller
682
+
683
+ beforeEach(() => {
684
+ document.body.innerHTML = `
685
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
686
+ <div data-comments--topics-target="list"></div>
687
+ </div>
688
+ `
689
+ Element.prototype.scrollIntoView = jest.fn()
690
+
691
+ application = Application.start()
692
+ application.register('comments--topics', TopicsController)
693
+
694
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
695
+ controller = application.getControllerForElementAndIdentifier(
696
+ document.getElementById('topics'), 'comments--topics'
697
+ )
698
+ controller.creativeIdValue = '42'
699
+ controller.topics = TOPICS
700
+ controller.archivedTopics = []
701
+ controller.mainTopicId = '1'
702
+ controller.canManageTopics = true
703
+ controller.showingArchived = false
704
+ controller.serverLastTopicId = ''
705
+ })
706
+ })
707
+
708
+ afterEach(() => {
709
+ document.body.innerHTML = ''
710
+ application.stop()
711
+ jest.clearAllMocks()
712
+ window.history.replaceState({}, '', '/')
713
+ })
714
+
715
+ test('a pick supersedes a deep-link override for a different topic', () => {
716
+ controller.setOverrideTopicId('2')
717
+
718
+ controller.selectTopic('3')
719
+
720
+ expect(controller.currentTopicId).toBe('3')
721
+ })
722
+
723
+ test('restoreSelection after such a pick stays on the picked topic', () => {
724
+ controller.setOverrideTopicId('2')
725
+ controller.selectTopic('3')
726
+
727
+ controller.restoreSelection()
728
+ controller.renderTopics(controller.topics, controller.canManageTopics)
729
+
730
+ expect(controller.currentTopicId).toBe('3')
731
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
732
+ .toContain('active')
733
+ })
734
+
735
+ test('an override agreeing with the pick is kept so it can still outrank the server', () => {
736
+ controller.setOverrideTopicId('3')
737
+
738
+ controller.selectTopic('3')
739
+
740
+ expect(controller.overrideTopicId).toBe('3')
741
+ controller.serverLastTopicId = '2' // as a later loadTopics() would write it
742
+ expect(controller.currentTopicId).toBe('3')
743
+ })
744
+
745
+ test('a pick drops a ?topic_id= naming a different topic', () => {
746
+ window.history.replaceState({}, '', '/creatives/42?topic_id=2')
747
+
748
+ controller.selectTopic('3')
749
+
750
+ expect(controller.currentTopicId).toBe('3')
751
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
752
+ })
753
+
754
+ test('a pick keeps a ?topic_id= naming the topic it selected', () => {
755
+ window.history.replaceState({}, '', '/creatives/42?topic_id=3')
756
+
757
+ controller.selectTopic('3')
758
+
759
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
760
+ })
761
+
762
+ test('selecting All Messages drops a ?topic_id= too', () => {
763
+ window.history.replaceState({}, '', '/creatives/42?topic_id=2')
764
+
765
+ controller.selectTopic('')
766
+
767
+ expect(controller.currentTopicId).toBe('')
768
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBeNull()
769
+ })
770
+
771
+ test('selecting All Messages drops a deep-link override too', () => {
772
+ controller.setOverrideTopicId('2')
773
+
774
+ controller.selectTopic('')
775
+
776
+ expect(controller.currentTopicId).toBe('')
777
+ })
778
+ })
779
+
780
+ // last_topic_changed is another session of the same user moving the saved
781
+ // preference. It is not a click in this popup, so it must not consume the
782
+ // deep-link sources this popup was opened on — they outrank the preference by
783
+ // design, and they are gone for good once released.
784
+ describe('TopicsController deep-link sources vs. a preference broadcast', () => {
785
+ let application
786
+ let controller
787
+
788
+ beforeEach(() => {
789
+ document.body.innerHTML = `
790
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
791
+ <div data-comments--topics-target="list"></div>
792
+ </div>
793
+ `
794
+ Element.prototype.scrollIntoView = jest.fn()
795
+
796
+ application = Application.start()
797
+ application.register('comments--topics', TopicsController)
798
+
799
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
800
+ controller = application.getControllerForElementAndIdentifier(
801
+ document.getElementById('topics'), 'comments--topics'
802
+ )
803
+ controller.creativeIdValue = '42'
804
+ controller.topics = TOPICS
805
+ controller.archivedTopics = []
806
+ controller.mainTopicId = '1'
807
+ controller.canManageTopics = true
808
+ controller.showingArchived = false
809
+ controller.serverLastTopicId = ''
810
+ controller.renderTopics(TOPICS, true)
811
+ })
812
+ })
813
+
814
+ afterEach(() => {
815
+ document.body.innerHTML = ''
816
+ application.stop()
817
+ jest.clearAllMocks()
818
+ window.history.replaceState({}, '', '/')
819
+ })
820
+
821
+ test('a broadcast does not consume a deep-link override', () => {
822
+ controller.setOverrideTopicId('3')
823
+
824
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
825
+
826
+ expect(controller.overrideTopicId).toBe('3')
827
+ expect(controller.currentTopicId).toBe('3')
828
+ })
829
+
830
+ test('a broadcast does not strip ?topic_id=', () => {
831
+ window.history.replaceState({}, '', '/creatives/42?topic_id=3')
832
+
833
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
834
+
835
+ expect(new URLSearchParams(window.location.search).get('topic_id')).toBe('3')
836
+ expect(controller.currentTopicId).toBe('3')
837
+ })
838
+
839
+ test('a broadcast leaves the linked topic selected in the strip', () => {
840
+ controller.setOverrideTopicId('3')
841
+
842
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
843
+ controller.restoreSelection()
844
+
845
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
846
+ .toContain('active')
847
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
848
+ .not.toContain('active')
849
+ })
850
+
851
+ test('the broadcast preference is still recorded behind the deep link', () => {
852
+ controller.setOverrideTopicId('3')
853
+
854
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
855
+
856
+ expect(controller.serverLastTopicId).toBe('2')
857
+ // Releasing the link hands the popup back to the preference the other
858
+ // session set, rather than to a value this popup never heard about.
859
+ controller.clearOverrideTopicId()
860
+ expect(controller.currentTopicId).toBe('2')
861
+ })
862
+
863
+ // comments_controller sends X-Topic-Id as effective_topic_id.to_s, so a
864
+ // comment that belongs to no topic resolves the deep link to All Messages and
865
+ // list_controller sets the override to "". It outranks the preference exactly
866
+ // like a named one — truthiness is the wrong test for "is a link set".
867
+ test('a broadcast does not consume a deep link that resolved to All Messages', () => {
868
+ controller.setOverrideTopicId('')
869
+
870
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
871
+
872
+ expect(controller.overrideTopicId).toBe('')
873
+ expect(controller.currentTopicId).toBe('')
874
+ })
875
+
876
+ test('a broadcast still moves the selection when no deep link outranks it', () => {
877
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
878
+
879
+ expect(controller.serverLastTopicId).toBe('2')
880
+ expect(controller.currentTopicId).toBe('2')
881
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="2"]').classList)
882
+ .toContain('active')
883
+ })
884
+
885
+ test('a pick after a broadcast still supersedes the deep link', () => {
886
+ controller.setOverrideTopicId('3')
887
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
888
+
889
+ controller.selectTopic('1')
890
+
891
+ expect(controller.currentTopicId).toBe('1')
892
+ })
893
+
894
+ // Every selection schedules a save, restores included, and the save is
895
+ // debounced by 500ms. Accepting a broadcast without following it leaves that
896
+ // timer holding the topic the link put us on, so it lands afterwards and
897
+ // writes the deep link back over the preference the other session just set.
898
+ describe('a pending save against an accepted broadcast', () => {
899
+ beforeEach(() => {
900
+ jest.useFakeTimers()
901
+ })
902
+
903
+ afterEach(() => {
904
+ controller.cancelPendingSaveLastTopic()
905
+ jest.clearAllTimers()
906
+ jest.useRealTimers()
907
+ })
908
+
909
+ test('cancels a zero-valued debounce handle after accepting a broadcast', () => {
910
+ const clearTimer = jest.spyOn(global, 'clearTimeout')
911
+ controller.setOverrideTopicId('3')
912
+ controller._saveLastTopicTimer = 0
913
+
914
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
915
+
916
+ expect(clearTimer).toHaveBeenCalledWith(0)
917
+ expect(controller._saveLastTopicTimer).toBeNull()
918
+ clearTimer.mockRestore()
919
+ })
920
+
921
+ test('the deep link is not written back over the broadcast preference', async () => {
922
+ window.history.replaceState({}, '', '/creatives/42?topic_id=3')
923
+ controller.restoreSelection()
924
+
925
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
926
+ await jest.advanceTimersByTimeAsync(500)
927
+
928
+ expect(saveLastTopic).not.toHaveBeenCalledWith('42', '3', expect.any(String), expect.anything())
929
+ })
930
+
931
+ // The broadcast is the preference now; nothing this popup had queued
932
+ // beforehand describes it, so nothing queued beforehand may be sent.
933
+ test('no save at all follows an accepted broadcast', async () => {
934
+ controller.setOverrideTopicId('3')
935
+ controller.restoreSelection()
936
+
937
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
938
+ await jest.advanceTimersByTimeAsync(500)
939
+
940
+ expect(saveLastTopic).not.toHaveBeenCalled()
941
+ expect(controller.serverLastTopicId).toBe('2')
942
+ })
943
+
944
+ // Cancelling is scoped to the accepted broadcast. A selection the user
945
+ // makes afterwards is theirs, and still has to reach the server.
946
+ test('a pick after the broadcast still saves', async () => {
947
+ controller.setOverrideTopicId('3')
948
+ controller.restoreSelection()
949
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
950
+
951
+ controller.selectTopic('1')
952
+ await jest.advanceTimersByTimeAsync(500)
953
+
954
+ expect(saveLastTopic).toHaveBeenCalledWith('42', '1', expect.any(String), expect.anything())
955
+ })
956
+
957
+ // With no link to hold the view, the broadcast is followed, and following
958
+ // it re-arms the debounce with the broadcast's own value — so the timer
959
+ // must not be left cancelled on that path.
960
+ test('a followed broadcast still persists its own value', async () => {
961
+ controller.restoreSelection()
962
+
963
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
964
+ await jest.advanceTimersByTimeAsync(500)
965
+
966
+ expect(saveLastTopic).toHaveBeenCalledWith('42', '2', expect.any(String), expect.anything())
967
+ })
968
+
969
+ test('a later restore leaves the broadcast preference behind the deep link', async () => {
970
+ controller.setOverrideTopicId('3')
971
+ controller.handleTopicMessage({ action: 'last_topic_changed', last_topic_id: 2 })
972
+
973
+ controller.handleTopicMessage({ action: 'updated', topic: { id: 1, name: 'Renamed Main' } })
974
+ await jest.advanceTimersByTimeAsync(500)
975
+
976
+ expect(controller.currentTopicId).toBe('3')
977
+ expect(controller.serverLastTopicId).toBe('2')
978
+ expect(saveLastTopic).not.toHaveBeenCalledWith('42', '3', expect.any(String), expect.anything())
979
+ })
980
+ })
981
+ })