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,1938 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ // The real helper resolves to response.ok — never throws, never undefined.
8
+ const saveLastTopic = jest.fn().mockResolvedValue(true)
9
+
10
+ jest.unstable_mockModule('../../../lib/api/topics', () => ({
11
+ fetchNextTopicName: jest.fn(),
12
+ createTopicWithComments: jest.fn(),
13
+ saveLastTopic,
14
+ }))
15
+
16
+ // Captures the lifecycle callbacks the controller registers, so a test can fire
17
+ // the ones ActionCable fires on a dropped or refused connection.
18
+ let subscriptionCallbacks
19
+ const createSubscription = jest.fn((_identifier, callbacks) => {
20
+ subscriptionCallbacks = callbacks
21
+ return { unsubscribe: jest.fn() }
22
+ })
23
+
24
+ jest.unstable_mockModule('../../../services/cable', () => ({
25
+ createSubscription,
26
+ }))
27
+
28
+ const { Application } = await import('@hotwired/stimulus')
29
+ const TopicsController = (await import('../topics_controller')).default
30
+
31
+ const TOPICS = [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }, { id: 3, name: 'Beta' }]
32
+
33
+ // update_last_topic broadcasts to every session of the current user, including
34
+ // the one that just saved, and the echo lands after the save that produced it —
35
+ // exactly the window a fast second pick falls into. Telling the echo apart from
36
+ // a sibling session's change is what the client_id on the broadcast is for; the
37
+ // topic id cannot do it, two sessions can pick the same topic at once.
38
+ describe('TopicsController vs. the echo of its own last_topic save', () => {
39
+ let application
40
+ let controller
41
+ let changeEvents
42
+ let originalFetch
43
+
44
+ beforeEach(() => {
45
+ originalFetch = global.fetch
46
+ window.sessionStorage.clear()
47
+ window.name = ''
48
+ document.body.innerHTML = `
49
+ <div id="topics" data-controller="comments--topics" data-topic-main-text="All Messages">
50
+ <div data-comments--topics-target="list"></div>
51
+ </div>
52
+ `
53
+ Element.prototype.scrollIntoView = jest.fn()
54
+
55
+ application = Application.start()
56
+ application.register('comments--topics', TopicsController)
57
+
58
+ changeEvents = []
59
+ document.addEventListener('comments--topics:change', (e) => changeEvents.push(e.detail))
60
+
61
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
62
+ controller = application.getControllerForElementAndIdentifier(
63
+ document.getElementById('topics'), 'comments--topics'
64
+ )
65
+ controller.creativeIdValue = '42'
66
+ controller.topics = TOPICS
67
+ controller.archivedTopics = []
68
+ controller.mainTopicId = '1'
69
+ controller.canManageTopics = true
70
+ controller.showingArchived = false
71
+ controller.serverLastTopicId = ''
72
+ controller.renderTopics(TOPICS, true)
73
+ })
74
+ })
75
+
76
+ afterEach(() => {
77
+ controller.cancelPendingSaveLastTopic()
78
+ window.sessionStorage.clear()
79
+ window.name = ''
80
+ global.fetch = originalFetch
81
+ document.body.innerHTML = ''
82
+ application.stop()
83
+ jest.clearAllMocks()
84
+ window.history.replaceState({}, '', '/')
85
+ })
86
+
87
+ // A broadcast from some other session of this user: it names a topic and
88
+ // nothing else. Without a client_id it cannot have come from this client.
89
+ const echo = (lastTopicId, clientId, lastTopicRevision) => controller.handleTopicMessage({
90
+ action: 'last_topic_changed',
91
+ last_topic_id: lastTopicId,
92
+ ...(lastTopicRevision === undefined ? {} : { last_topic_revision: lastTopicRevision }),
93
+ ...(clientId === undefined ? {} : { client_id: clientId }),
94
+ })
95
+
96
+ // The echo of a save this client made: the server hands back the id that
97
+ // save was sent with, which is what makes it identifiable as ours.
98
+ const clientIdFor = (lastTopicId) => {
99
+ const call = saveLastTopic.mock.calls.find(c => String(c[1] ?? '') === String(lastTopicId ?? ''))
100
+ return call && call[2]
101
+ }
102
+
103
+ const selfEcho = (lastTopicId, lastTopicRevision) => echo(lastTopicId, clientIdFor(lastTopicId), lastTopicRevision)
104
+
105
+ test('keeps the save fence identity and sequence across controller replacement', () => {
106
+ const firstClientId = controller.newLastTopicSaveClientId()
107
+ const replacement = Object.create(TopicsController.prototype)
108
+ const secondClientId = replacement.newLastTopicSaveClientId()
109
+
110
+ const [firstSessionId, firstSequence] = firstClientId.split('.')
111
+ const [secondSessionId, secondSequence] = secondClientId.split('.')
112
+ expect(secondSessionId).toBe(firstSessionId)
113
+ expect(Number(secondSequence)).toBe(Number(firstSequence) + 1)
114
+ })
115
+
116
+ test('gives a duplicated tab a new save fence identity', () => {
117
+ const firstClientId = controller.newLastTopicSaveClientId()
118
+ // A duplicated tab receives copied sessionStorage but starts with its own
119
+ // top-level browsing context, so it has no window.name from the source tab.
120
+ window.name = ''
121
+ const duplicate = Object.create(TopicsController.prototype)
122
+ const duplicateClientId = duplicate.newLastTopicSaveClientId()
123
+
124
+ expect(duplicateClientId.split('.')[0]).not.toBe(firstClientId.split('.')[0])
125
+ })
126
+
127
+ test('a linked-shell Turbo replacement ignores an orphaned self echo after a newer pick', async () => {
128
+ controller.knownEffectiveCreativeIds.set('42', '7')
129
+ controller.element.dataset.effectiveCreativeId = '7'
130
+ let resolveSave
131
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
132
+ const save = controller.flushSaveLastTopic('2')
133
+ await Promise.resolve()
134
+ const alphaClientId = clientIdFor('2')
135
+
136
+ document.getElementById('topics').remove()
137
+ await new Promise(resolve => setTimeout(resolve, 0))
138
+ document.body.innerHTML = `
139
+ <div id="replacement" data-controller="comments--topics" data-topic-main-text="All Messages">
140
+ <div data-comments--topics-target="list"></div>
141
+ </div>
142
+ `
143
+ await new Promise(resolve => setTimeout(resolve, 0))
144
+ const replacement = application.getControllerForElementAndIdentifier(
145
+ document.getElementById('replacement'), 'comments--topics'
146
+ )
147
+ replacement.creativeIdValue = '42'
148
+ replacement.knownEffectiveCreativeIds.set('42', '7')
149
+ replacement.element.dataset.effectiveCreativeId = '7'
150
+ replacement.topics = TOPICS
151
+ replacement.archivedTopics = []
152
+ replacement.mainTopicId = '1'
153
+ replacement.canManageTopics = true
154
+ replacement.showingArchived = false
155
+ replacement.serverLastTopicId = ''
156
+ replacement.renderTopics(TOPICS, true)
157
+ replacement.selectTopic('3')
158
+
159
+ replacement.handleTopicMessage({
160
+ action: 'last_topic_changed',
161
+ last_topic_id: '2',
162
+ client_id: alphaClientId,
163
+ })
164
+
165
+ expect(replacement.currentTopicId).toBe('3')
166
+ expect(replacement._pendingPick.topicId).toBe('3')
167
+ expect(saveLastTopic).toHaveBeenCalledTimes(1)
168
+ resolveSave(true)
169
+ await save
170
+ controller = replacement
171
+ })
172
+
173
+ test('a Turbo replacement applies an orphaned self echo without a newer pick', async () => {
174
+ let resolveSave
175
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
176
+ const save = controller.flushSaveLastTopic('2')
177
+ await Promise.resolve()
178
+ const alphaClientId = clientIdFor('2')
179
+
180
+ document.getElementById('topics').remove()
181
+ await new Promise(resolve => setTimeout(resolve, 0))
182
+ document.body.innerHTML = `
183
+ <div id="replacement" data-controller="comments--topics" data-topic-main-text="All Messages">
184
+ <div data-comments--topics-target="list"></div>
185
+ </div>
186
+ `
187
+ await new Promise(resolve => setTimeout(resolve, 0))
188
+ const replacement = application.getControllerForElementAndIdentifier(
189
+ document.getElementById('replacement'), 'comments--topics'
190
+ )
191
+ replacement.creativeIdValue = '42'
192
+ replacement.topics = TOPICS
193
+ replacement.archivedTopics = []
194
+ replacement.mainTopicId = '1'
195
+ replacement.canManageTopics = true
196
+ replacement.showingArchived = false
197
+ replacement.serverLastTopicId = ''
198
+ replacement.renderTopics(TOPICS, true)
199
+
200
+ replacement.handleTopicMessage({
201
+ action: 'last_topic_changed',
202
+ last_topic_id: '2',
203
+ client_id: alphaClientId,
204
+ })
205
+
206
+ expect(replacement.currentTopicId).toBe('2')
207
+ expect(replacement.serverLastTopicId).toBe('2')
208
+ expect(replacement._pendingPick).toBeUndefined()
209
+ resolveSave(true)
210
+ await save
211
+ controller = replacement
212
+ })
213
+
214
+ test('ignores a sibling broadcast older than the observed revision', () => {
215
+ echo('3', 'sibling-newer', [5, 2])
216
+ echo('2', 'sibling-older', [5, 1])
217
+
218
+ expect(controller.serverLastTopicId).toBe('3')
219
+ expect(controller.currentTopicId).toBe('3')
220
+ expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
221
+ })
222
+
223
+ test('does not recreate acknowledgement metadata when the echo arrives before the response', async () => {
224
+ let resolveSave
225
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
226
+
227
+ const save = controller.flushSaveLastTopic('2')
228
+ await Promise.resolve()
229
+ const clientId = clientIdFor('2')
230
+
231
+ echo('2', clientId)
232
+ expect(controller.pendingSelfEchoes).not.toContain(clientId)
233
+
234
+ resolveSave(true)
235
+ await save
236
+
237
+ expect(controller.pendingSelfEchoAcknowledgementVersions.has(clientId)).toBe(false)
238
+ })
239
+
240
+ test('an early echo keeps its save order until an older load is processed', async () => {
241
+ let resolveSave
242
+ let resolveSnapshot
243
+ controller.lastKnownRemoteTopicId = '1'
244
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
245
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
246
+
247
+ const load = controller.loadTopics()
248
+ controller.selectTopic('2')
249
+ const save = controller.flushSaveLastTopic('2')
250
+ await Promise.resolve()
251
+
252
+ selfEcho('2')
253
+ resolveSave(true)
254
+ await save
255
+ resolveSnapshot({
256
+ ok: true,
257
+ json: async () => ({
258
+ topics: TOPICS,
259
+ archived_topics: [],
260
+ can_manage: true,
261
+ last_topic_id: '1',
262
+ main_topic_id: '1',
263
+ effective_creative_id: '42',
264
+ }),
265
+ })
266
+ await load
267
+
268
+ expect(controller.currentTopicId).toBe('2')
269
+ expect(controller.serverLastTopicId).toBe('2')
270
+ expect(controller.pendingSelfEchoAcknowledgementVersions.has(clientIdFor('2'))).toBe(false)
271
+ })
272
+
273
+ test('an early echo keeps its save order for a reopened load with a later request number', async () => {
274
+ let resolveSave
275
+ let resolveSnapshot
276
+ controller.lastKnownRemoteTopicId = '1'
277
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
278
+
279
+ controller.selectTopic('2')
280
+ const save = controller.flushSaveLastTopic('2')
281
+ await Promise.resolve()
282
+
283
+ controller.onPopupClosed()
284
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
285
+ const reopen = controller.onPopupOpened({ creativeId: '42' })
286
+ await Promise.resolve()
287
+
288
+ selfEcho('2')
289
+ resolveSave(true)
290
+ await save
291
+ resolveSnapshot({
292
+ ok: true,
293
+ json: async () => ({
294
+ topics: TOPICS,
295
+ archived_topics: [],
296
+ can_manage: true,
297
+ last_topic_id: '1',
298
+ main_topic_id: '1',
299
+ effective_creative_id: '42',
300
+ }),
301
+ })
302
+ await reopen
303
+
304
+ expect(controller.currentTopicId).toBe('2')
305
+ expect(controller.serverLastTopicId).toBe('2')
306
+ })
307
+
308
+ test('a closed save yields when its early echo precedes a newer reopen snapshot', async () => {
309
+ let resolveSave
310
+ let resolveSnapshot
311
+ controller.lastKnownRemoteTopicId = '1'
312
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
313
+
314
+ controller.selectTopic('2')
315
+ const save = controller.flushSaveLastTopic('2')
316
+ await Promise.resolve()
317
+ controller.onPopupClosed()
318
+
319
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
320
+ const reopen = controller.onPopupOpened({ creativeId: '42' })
321
+ await Promise.resolve()
322
+
323
+ selfEcho('2')
324
+ resolveSnapshot({
325
+ ok: true,
326
+ json: async () => ({
327
+ topics: TOPICS,
328
+ archived_topics: [],
329
+ can_manage: true,
330
+ last_topic_id: '3',
331
+ main_topic_id: '1',
332
+ effective_creative_id: '42',
333
+ }),
334
+ })
335
+ await reopen
336
+
337
+ expect(controller.currentTopicId).toBe('3')
338
+ resolveSave(true)
339
+ await save
340
+ })
341
+
342
+ test('a newer ABA reopen snapshot yields to the server revision, not its repeated topic id', async () => {
343
+ let resolveSave
344
+ let resolveSnapshot
345
+ controller.lastKnownRemoteTopicId = '1'
346
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
347
+
348
+ controller.selectTopic('2')
349
+ const save = controller.flushSaveLastTopic('2')
350
+ await Promise.resolve()
351
+ controller.onPopupClosed()
352
+
353
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
354
+ const reopen = controller.onPopupOpened({ creativeId: '42' })
355
+ await Promise.resolve()
356
+
357
+ // The server accepted Alpha, then a sibling session deliberately chose
358
+ // Main again. The values form an ABA sequence, but revision [5, 2] is
359
+ // newer than the self-echo's [5, 1].
360
+ selfEcho('2', [5, 1])
361
+ resolveSnapshot({
362
+ ok: true,
363
+ json: async () => ({
364
+ topics: TOPICS,
365
+ archived_topics: [],
366
+ can_manage: true,
367
+ last_topic_id: '1',
368
+ last_topic_revision: [5, 2],
369
+ main_topic_id: '1',
370
+ effective_creative_id: '42',
371
+ }),
372
+ })
373
+ await reopen
374
+
375
+ expect(controller.currentTopicId).toBe('1')
376
+ resolveSave(true)
377
+ await save
378
+ })
379
+
380
+ test('a PATCH acknowledgement orders a delayed ABA snapshot before its self-echo', async () => {
381
+ let resolveSave
382
+ let resolveSnapshot
383
+ controller.lastKnownRemoteTopicId = '1'
384
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
385
+
386
+ controller.selectTopic('2')
387
+ const save = controller.flushSaveLastTopic('2')
388
+ await Promise.resolve()
389
+
390
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
391
+ const load = controller.loadTopics()
392
+ await Promise.resolve()
393
+
394
+ // This client's Alpha commit is revision [5, 1]. Before its self-echo
395
+ // arrives, another session selects Main again at [5, 2]. The request
396
+ // response must preserve [5, 1] so the GET is not mistaken for the
397
+ // stale Main snapshot from before the local save.
398
+ resolveSave({ success: true, lastTopicRevision: [5, 1] })
399
+ await save
400
+ resolveSnapshot({
401
+ ok: true,
402
+ json: async () => ({
403
+ topics: TOPICS,
404
+ archived_topics: [],
405
+ can_manage: true,
406
+ last_topic_id: '1',
407
+ last_topic_revision: [5, 2],
408
+ main_topic_id: '1',
409
+ effective_creative_id: '42',
410
+ }),
411
+ })
412
+ await load
413
+
414
+ expect(controller.currentTopicId).toBe('1')
415
+ })
416
+
417
+ test('waits for a closed pending save before reconciling a revisioned ABA snapshot', async () => {
418
+ let resolveSave
419
+ controller.lastKnownRemoteTopicId = '1'
420
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
421
+ global.fetch = jest.fn()
422
+ .mockResolvedValueOnce({
423
+ ok: true,
424
+ json: async () => ({
425
+ topics: TOPICS,
426
+ archived_topics: [],
427
+ can_manage: true,
428
+ last_topic_id: '1',
429
+ last_topic_revision: [5, 2],
430
+ main_topic_id: '1',
431
+ effective_creative_id: '42',
432
+ }),
433
+ })
434
+ .mockResolvedValueOnce({
435
+ ok: true,
436
+ json: async () => ({
437
+ topics: TOPICS,
438
+ archived_topics: [],
439
+ can_manage: true,
440
+ last_topic_id: '1',
441
+ last_topic_revision: [5, 2],
442
+ main_topic_id: '1',
443
+ effective_creative_id: '42',
444
+ }),
445
+ })
446
+
447
+ controller.selectTopic('2')
448
+ const save = controller.flushSaveLastTopic('2')
449
+ await Promise.resolve()
450
+ controller.onPopupClosed()
451
+ await controller.onPopupOpened({ creativeId: '42' })
452
+
453
+ // Alpha's response and echo are both late, so the Main snapshot could
454
+ // either predate Alpha or be a sibling's later ABA write. Do not replay
455
+ // Alpha through that ambiguity.
456
+ expect(controller.currentTopicId).toBe('2')
457
+ expect(saveLastTopic).toHaveBeenCalledTimes(1)
458
+
459
+ resolveSave({ success: true, lastTopicRevision: [5, 1] })
460
+ await save
461
+ await new Promise(resolve => setTimeout(resolve, 0))
462
+
463
+ expect(global.fetch).toHaveBeenCalledTimes(2)
464
+ expect(controller.currentTopicId).toBe('1')
465
+ })
466
+
467
+ test('retries deferred reconciliation when a self-echo confirms an ambiguous save', async () => {
468
+ let rejectSave
469
+ controller.lastKnownRemoteTopicId = '1'
470
+ saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
471
+ global.fetch = jest.fn()
472
+ .mockResolvedValueOnce({
473
+ ok: true,
474
+ json: async () => ({
475
+ topics: TOPICS,
476
+ archived_topics: [],
477
+ can_manage: true,
478
+ last_topic_id: '1',
479
+ last_topic_revision: [5, 2],
480
+ main_topic_id: '1',
481
+ effective_creative_id: '42',
482
+ }),
483
+ })
484
+ .mockResolvedValueOnce({
485
+ ok: true,
486
+ json: async () => ({
487
+ topics: TOPICS,
488
+ archived_topics: [],
489
+ can_manage: true,
490
+ last_topic_id: '1',
491
+ last_topic_revision: [5, 2],
492
+ main_topic_id: '1',
493
+ effective_creative_id: '42',
494
+ }),
495
+ })
496
+
497
+ controller.selectTopic('2')
498
+ const save = controller.flushSaveLastTopic('2')
499
+ await Promise.resolve()
500
+ controller.onPopupClosed()
501
+ await controller.onPopupOpened({ creativeId: '42' })
502
+
503
+ // A network failure does not prove the PATCH rolled back. Its identified
504
+ // echo confirms Alpha at [5, 1], so the deferred Main [5, 2] snapshot
505
+ // must be retried and win by revision.
506
+ rejectSave(new Error('network'))
507
+ await save
508
+ selfEcho('2', [5, 1])
509
+ await new Promise(resolve => setTimeout(resolve, 0))
510
+
511
+ expect(global.fetch).toHaveBeenCalledTimes(2)
512
+ expect(controller.currentTopicId).toBe('1')
513
+ })
514
+
515
+ test('retires an ambiguous save with no echo and retries deferred reconciliation', async () => {
516
+ let rejectSave
517
+ jest.useFakeTimers()
518
+ try {
519
+ controller.lastKnownRemoteTopicId = '1'
520
+ saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
521
+ global.fetch = jest.fn()
522
+ .mockResolvedValueOnce({
523
+ ok: true,
524
+ json: async () => ({
525
+ topics: TOPICS,
526
+ archived_topics: [],
527
+ can_manage: true,
528
+ last_topic_id: '1',
529
+ last_topic_revision: [5, 2],
530
+ main_topic_id: '1',
531
+ effective_creative_id: '42',
532
+ }),
533
+ })
534
+ .mockResolvedValueOnce({
535
+ ok: true,
536
+ json: async () => ({
537
+ topics: TOPICS,
538
+ archived_topics: [],
539
+ can_manage: true,
540
+ last_topic_id: '1',
541
+ last_topic_revision: [5, 2],
542
+ main_topic_id: '1',
543
+ effective_creative_id: '42',
544
+ }),
545
+ })
546
+
547
+ controller.selectTopic('2')
548
+ const save = controller.flushSaveLastTopic('2')
549
+ await Promise.resolve()
550
+ controller.onPopupClosed()
551
+ await controller.onPopupOpened({ creativeId: '42' })
552
+
553
+ rejectSave(new Error('network'))
554
+ await save
555
+ expect(controller.pendingSelfEchoes).toContain(clientIdFor('2'))
556
+
557
+ await jest.advanceTimersByTimeAsync(5_000)
558
+
559
+ expect(controller.pendingSelfEchoes).toEqual([])
560
+ expect(global.fetch).toHaveBeenCalledTimes(2)
561
+ expect(controller.currentTopicId).toBe('1')
562
+ } finally {
563
+ jest.useRealTimers()
564
+ }
565
+ })
566
+
567
+ test('advances the serialized save queue when a request never settles', async () => {
568
+ jest.useFakeTimers()
569
+ try {
570
+ let firstSignal
571
+ saveLastTopic.mockImplementationOnce((_creativeId, _topicId, _clientId, signal) => {
572
+ firstSignal = signal
573
+ return new Promise((_resolve, reject) => {
574
+ signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true })
575
+ })
576
+ })
577
+ const first = controller.flushSaveLastTopic('2')
578
+ const second = controller.flushSaveLastTopic('3')
579
+ await Promise.resolve()
580
+
581
+ expect(saveLastTopic).toHaveBeenCalledTimes(1)
582
+ await jest.advanceTimersByTimeAsync(5_000)
583
+ await Promise.all([first, second])
584
+
585
+ expect(firstSignal.aborted).toBe(true)
586
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.any(AbortSignal))
587
+ } finally {
588
+ jest.useRealTimers()
589
+ }
590
+ })
591
+
592
+ test('ignores a late echo from a retired ambiguous save', async () => {
593
+ let rejectSave
594
+ jest.useFakeTimers()
595
+ try {
596
+ saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
597
+ const save = controller.flushSaveLastTopic('2')
598
+ await Promise.resolve()
599
+ const clientId = clientIdFor('2')
600
+ rejectSave(new Error('network'))
601
+ await save
602
+
603
+ await jest.advanceTimersByTimeAsync(5_000)
604
+ controller.selectTopic('3')
605
+ echo('2', clientId)
606
+
607
+ expect(controller.currentTopicId).toBe('3')
608
+ expect(controller.serverLastTopicId).not.toBe('2')
609
+ } finally {
610
+ jest.useRealTimers()
611
+ }
612
+ })
613
+
614
+ test('compacts retired ambiguous save tombstones by tab-session sequence', () => {
615
+ const clientIds = Array.from({ length: 100 }, () => controller.newLastTopicSaveClientId())
616
+ for (const clientId of clientIds) {
617
+ controller.pendingSelfEchoes.push(clientId)
618
+ controller.pendingSelfEchoCreativeIds.set(clientId, '42')
619
+ controller.retirePendingSelfEcho(clientId)
620
+ }
621
+
622
+ const [sessionId, highestSequence] = clientIds.at(-1).split('.')
623
+ expect(controller.retiredSelfEchoSequenceHighWaters).toEqual(
624
+ new Map([[sessionId, Number(highestSequence)]])
625
+ )
626
+
627
+ controller.selectTopic('3')
628
+ echo('2', clientIds[0])
629
+ expect(controller.currentTopicId).toBe('3')
630
+ })
631
+
632
+ test('records the revision of a late echo from a retired ambiguous save', async () => {
633
+ let rejectSave
634
+ let resolveSnapshot
635
+ jest.useFakeTimers()
636
+ try {
637
+ controller.serverLastTopicId = '2'
638
+ saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectSave = reject }))
639
+ const save = controller.flushSaveLastTopic('2')
640
+ await Promise.resolve()
641
+ const clientId = clientIdFor('2')
642
+ rejectSave(new Error('network'))
643
+ await save
644
+ await jest.advanceTimersByTimeAsync(5_000)
645
+
646
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
647
+ const load = controller.loadTopics()
648
+ await Promise.resolve()
649
+ echo('2', clientId, [5, 2])
650
+ resolveSnapshot({
651
+ ok: true,
652
+ json: async () => ({
653
+ topics: TOPICS,
654
+ archived_topics: [],
655
+ can_manage: true,
656
+ last_topic_id: '1',
657
+ last_topic_revision: [5, 1],
658
+ main_topic_id: '1',
659
+ effective_creative_id: '42',
660
+ }),
661
+ })
662
+ await load
663
+
664
+ expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
665
+ expect(controller.serverLastTopicId).toBe('2')
666
+ } finally {
667
+ jest.useRealTimers()
668
+ }
669
+ })
670
+
671
+ test('a committed revision outranks a snapshot with a different predecessor topic', async () => {
672
+ let resolveSave
673
+ let resolveSnapshot
674
+ controller.lastKnownRemoteTopicId = '1'
675
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
676
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
677
+
678
+ controller.selectTopic('3')
679
+ const save = controller.flushSaveLastTopic('3')
680
+ await Promise.resolve()
681
+ const load = controller.loadTopics()
682
+ await Promise.resolve()
683
+
684
+ // Another session chose Alpha after this client observed Main, but before
685
+ // the local Beta save committed. The acknowledgement proves Beta's [5, 2]
686
+ // revision is newer than the GET's Alpha [5, 1] snapshot, even though
687
+ // Alpha is not Beta's predecessor topic.
688
+ resolveSave({ success: true, lastTopicRevision: [5, 2] })
689
+ await save
690
+ resolveSnapshot({
691
+ ok: true,
692
+ json: async () => ({
693
+ topics: TOPICS,
694
+ archived_topics: [],
695
+ can_manage: true,
696
+ last_topic_id: '2',
697
+ last_topic_revision: [5, 1],
698
+ main_topic_id: '1',
699
+ effective_creative_id: '42',
700
+ }),
701
+ })
702
+ await load
703
+
704
+ expect(controller.currentTopicId).toBe('3')
705
+ expect(controller.serverLastTopicId).toBe('3')
706
+ })
707
+
708
+ test('an early linked-shell echo advances the resolved stream baseline', async () => {
709
+ let resolveSave
710
+ let resolveSnapshot
711
+ controller.creativeIdValue = '77'
712
+ controller.element.dataset.effectiveCreativeId = '42'
713
+ controller.setLastKnownRemoteTopicId('42', '1')
714
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
715
+
716
+ controller.selectTopic('2')
717
+ const save = controller.flushSaveLastTopic('2')
718
+ await Promise.resolve()
719
+ controller.onPopupClosed()
720
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
721
+ const reopen = controller.onPopupOpened({ creativeId: '77' })
722
+ await Promise.resolve()
723
+
724
+ selfEcho('2', [5, 1])
725
+ expect(controller.lastKnownRemoteTopicIdFor('42')).toBe('2')
726
+ expect(controller.lastKnownRemoteTopicIdFor('77')).toBeUndefined()
727
+
728
+ resolveSnapshot({
729
+ ok: true,
730
+ json: async () => ({
731
+ topics: TOPICS,
732
+ archived_topics: [],
733
+ can_manage: true,
734
+ last_topic_id: '2',
735
+ last_topic_revision: [5, 1],
736
+ main_topic_id: '1',
737
+ effective_creative_id: '42',
738
+ }),
739
+ })
740
+ await reopen
741
+ resolveSave(true)
742
+ await save
743
+ })
744
+
745
+ // Pick Alpha, let its debounced save go out, then pick Beta before the echo
746
+ // for Alpha gets back. The echo names the topic the user has just left.
747
+ test('a pick made before the echo lands is not reverted by it', async () => {
748
+ controller.selectTopic('2')
749
+ await controller.flushSaveLastTopic('2')
750
+
751
+ controller.selectTopic('3')
752
+ selfEcho('2')
753
+
754
+ expect(controller.currentTopicId).toBe('3')
755
+ expect(changeEvents.at(-1).topicId).toBe('3')
756
+ expect(controller.listTarget.querySelector('.topic-tag[data-id="3"]').classList)
757
+ .toContain('active')
758
+ })
759
+
760
+ // ...and the revert must not be written back as the preference either.
761
+ test('the stale echo is not persisted over the pick', async () => {
762
+ controller.selectTopic('2')
763
+ await controller.flushSaveLastTopic('2')
764
+
765
+ controller.selectTopic('3')
766
+ selfEcho('2')
767
+ await controller.flushSaveLastTopic(controller.currentTopicId)
768
+
769
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
770
+ })
771
+
772
+ // Same race for All Messages, which the strip renders as its own chip.
773
+ test('an All Messages pick is not reverted by the echo of the topic it left', async () => {
774
+ controller.selectTopic('2')
775
+ await controller.flushSaveLastTopic('2')
776
+
777
+ controller.selectTopic('')
778
+ selfEcho('2')
779
+
780
+ expect(controller.currentTopicId).toBe('')
781
+ })
782
+
783
+ // The point of the broadcast is cross-tab sync, and that has to keep working:
784
+ // a change this client did not make is real news, not an echo.
785
+ test('a change from another session is still applied', () => {
786
+ echo('3')
787
+
788
+ expect(controller.currentTopicId).toBe('3')
789
+ expect(changeEvents.at(-1).topicId).toBe('3')
790
+ })
791
+
792
+ test('another session is still applied after this one has saved something else', async () => {
793
+ controller.selectTopic('2')
794
+ await controller.flushSaveLastTopic('2')
795
+
796
+ echo('3')
797
+
798
+ expect(controller.currentTopicId).toBe('3')
799
+ })
800
+
801
+ // A rejected save broadcasts nothing — update_last_topic returns before the
802
+ // broadcast on a denied read or a topic outside the creative — so the client
803
+ // must not go on waiting for an echo that is never coming.
804
+ test('a save that did not land leaves no claim on a later echo', async () => {
805
+ saveLastTopic.mockResolvedValueOnce(false)
806
+ controller.selectTopic('2')
807
+ await controller.flushSaveLastTopic('2')
808
+
809
+ controller.selectTopic('3')
810
+ // Another session moves the preference to Alpha for real.
811
+ echo('2')
812
+
813
+ expect(controller.currentTopicId).toBe('2')
814
+ })
815
+
816
+ test('a stale rejection clears its pending pick before reconciliation', async () => {
817
+ saveLastTopic.mockResolvedValueOnce({
818
+ success: false,
819
+ staleLastTopicSave: true,
820
+ lastTopicRevision: [5, 2],
821
+ })
822
+ controller.deferredLastTopicReconciliations.add('42')
823
+ controller.loadTopics = jest.fn(() => {
824
+ expect(controller._pendingPick).toBeNull()
825
+ })
826
+
827
+ controller.selectTopic('2')
828
+ await controller.flushSaveLastTopic('2')
829
+
830
+ expect(controller._pendingPick).toBeNull()
831
+ expect(controller.highestLastTopicRevisions.get('42')).toEqual([5, 2])
832
+ expect(controller.loadTopics).toHaveBeenCalledTimes(1)
833
+ })
834
+
835
+ test('a stale rejection does not clear a later pick of the same topic', async () => {
836
+ let resolveSave
837
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
838
+
839
+ controller.selectTopic('2')
840
+ const save = controller.flushSaveLastTopic('2')
841
+ await Promise.resolve()
842
+ controller.selectTopic('2')
843
+ const laterPick = controller._pendingPick
844
+
845
+ resolveSave({ success: false, staleLastTopicSave: true })
846
+ await save
847
+
848
+ expect(controller._pendingPick).toBe(laterPick)
849
+ })
850
+
851
+ // The stream is per creative, so leaving it for another means the echoes it
852
+ // owed us are not coming. Nothing can settle those claims; they go with it.
853
+ test('leaving the stream drops outstanding claims', async () => {
854
+ controller.selectTopic('2')
855
+ await controller.flushSaveLastTopic('2')
856
+
857
+ controller.unsubscribe()
858
+
859
+ expect(controller.pendingSelfEchoes).toHaveLength(0)
860
+ controller.selectTopic('3')
861
+ echo('2')
862
+ expect(controller.currentTopicId).toBe('2')
863
+ })
864
+
865
+ test('closing and reopening the same creative keeps an in-flight claim', async () => {
866
+ let resolveSave
867
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
868
+ controller.selectTopic('2')
869
+ const save = controller.flushSaveLastTopic('2')
870
+ await Promise.resolve()
871
+
872
+ controller.onPopupClosed()
873
+ controller.loadTopics = jest.fn()
874
+ controller.onPopupOpened({ creativeId: '42' })
875
+ controller.selectTopic('3')
876
+ selfEcho('2')
877
+
878
+ expect(controller.currentTopicId).toBe('3')
879
+ resolveSave(true)
880
+ await save
881
+ })
882
+
883
+ test('a reopen does not restore a stale preference over its pending save', async () => {
884
+ let resolveSave
885
+ controller.lastKnownRemoteTopicId = '1'
886
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
887
+ controller.selectTopic('2')
888
+ const save = controller.flushSaveLastTopic('2')
889
+ await Promise.resolve()
890
+
891
+ controller.onPopupClosed()
892
+ global.fetch = jest.fn().mockResolvedValue({
893
+ ok: true,
894
+ json: async () => ({
895
+ topics: TOPICS,
896
+ archived_topics: [],
897
+ can_manage: true,
898
+ last_topic_id: '1',
899
+ main_topic_id: '1',
900
+ effective_creative_id: '42',
901
+ }),
902
+ })
903
+ await controller.onPopupOpened({ creativeId: '42' })
904
+
905
+ expect(controller.currentTopicId).toBe('2')
906
+ resolveSave(true)
907
+ await save
908
+ await controller.flushSaveLastTopic(controller.currentTopicId)
909
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
910
+
911
+ selfEcho('2')
912
+ expect(controller.currentTopicId).toBe('2')
913
+ })
914
+
915
+ test('a stale reopen snapshot still yields to a save acknowledged after its load began', async () => {
916
+ let resolveSave
917
+ let resolveSnapshot
918
+ controller.lastKnownRemoteTopicId = '1'
919
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
920
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
921
+
922
+ controller.selectTopic('2')
923
+ const save = controller.flushSaveLastTopic('2')
924
+ await Promise.resolve()
925
+ controller.onPopupClosed()
926
+ const reopen = controller.onPopupOpened({ creativeId: '42' })
927
+ await Promise.resolve()
928
+
929
+ resolveSave(true)
930
+ await save
931
+ resolveSnapshot({
932
+ ok: true,
933
+ json: async () => ({
934
+ topics: TOPICS,
935
+ archived_topics: [],
936
+ can_manage: true,
937
+ last_topic_id: '1',
938
+ main_topic_id: '1',
939
+ effective_creative_id: '42',
940
+ }),
941
+ })
942
+ await reopen
943
+
944
+ expect(controller.currentTopicId).toBe('2')
945
+ expect(controller.serverLastTopicId).toBe('2')
946
+ })
947
+
948
+ test('a stale reopen snapshot yields to every save acknowledged after its load began', async () => {
949
+ let resolveAlphaSave
950
+ let resolveBetaSave
951
+ let resolveSnapshot
952
+ controller.lastKnownRemoteTopicId = '1'
953
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveAlphaSave = resolve }))
954
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveBetaSave = resolve }))
955
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
956
+
957
+ controller.selectTopic('2')
958
+ const alphaSave = controller.flushSaveLastTopic('2')
959
+ await Promise.resolve()
960
+ controller.selectTopic('3')
961
+ const betaSave = controller.flushSaveLastTopic('3')
962
+ controller.onPopupClosed()
963
+ const reopen = controller.onPopupOpened({ creativeId: '42' })
964
+ await Promise.resolve()
965
+
966
+ resolveAlphaSave(true)
967
+ await alphaSave
968
+ await Promise.resolve()
969
+ resolveBetaSave(true)
970
+ await betaSave
971
+ resolveSnapshot({
972
+ ok: true,
973
+ json: async () => ({
974
+ topics: TOPICS,
975
+ archived_topics: [],
976
+ can_manage: true,
977
+ last_topic_id: '1',
978
+ main_topic_id: '1',
979
+ effective_creative_id: '42',
980
+ }),
981
+ })
982
+ await reopen
983
+
984
+ expect(controller.currentTopicId).toBe('3')
985
+ expect(controller.serverLastTopicId).toBe('3')
986
+ })
987
+
988
+ test('a stale load does not roll back the baseline for a later closed save', async () => {
989
+ let resolveSnapshot
990
+ let resolveBetaSave
991
+ controller.lastKnownRemoteTopicId = '1'
992
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
993
+
994
+ const staleLoad = controller.loadTopics()
995
+ controller.selectTopic('2')
996
+ await controller.flushSaveLastTopic('2')
997
+
998
+ resolveSnapshot({
999
+ ok: true,
1000
+ json: async () => ({
1001
+ topics: TOPICS,
1002
+ archived_topics: [],
1003
+ can_manage: true,
1004
+ last_topic_id: '1',
1005
+ main_topic_id: '1',
1006
+ effective_creative_id: '42',
1007
+ }),
1008
+ })
1009
+ await staleLoad
1010
+ expect(controller.currentTopicId).toBe('2')
1011
+ expect(controller.lastKnownRemoteTopicId).toBe('2')
1012
+
1013
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveBetaSave = resolve }))
1014
+ controller.selectTopic('3')
1015
+ const betaSave = controller.flushSaveLastTopic('3')
1016
+ await Promise.resolve()
1017
+ controller.onPopupClosed()
1018
+ global.fetch = jest.fn().mockResolvedValue({
1019
+ ok: true,
1020
+ json: async () => ({
1021
+ topics: TOPICS,
1022
+ archived_topics: [],
1023
+ can_manage: true,
1024
+ last_topic_id: '2',
1025
+ main_topic_id: '1',
1026
+ effective_creative_id: '42',
1027
+ }),
1028
+ })
1029
+ await controller.onPopupOpened({ creativeId: '42' })
1030
+
1031
+ expect(controller.currentTopicId).toBe('3')
1032
+ resolveBetaSave(true)
1033
+ await betaSave
1034
+ })
1035
+
1036
+ test('a picked value keeps its in-flight snapshot as the baseline for a later closed save', async () => {
1037
+ let resolveInitialSnapshot
1038
+ let resolveSave
1039
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveInitialSnapshot = resolve }))
1040
+
1041
+ const initialLoad = controller.loadTopics()
1042
+ controller.selectTopic('2')
1043
+ resolveInitialSnapshot({
1044
+ ok: true,
1045
+ json: async () => ({
1046
+ topics: TOPICS,
1047
+ archived_topics: [],
1048
+ can_manage: true,
1049
+ last_topic_id: '1',
1050
+ main_topic_id: '1',
1051
+ effective_creative_id: '42',
1052
+ }),
1053
+ })
1054
+ await initialLoad
1055
+
1056
+ expect(controller.currentTopicId).toBe('2')
1057
+ expect(controller.lastKnownRemoteTopicId).toBe('1')
1058
+
1059
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1060
+ const save = controller.flushSaveLastTopic('2')
1061
+ await Promise.resolve()
1062
+ controller.onPopupClosed()
1063
+ global.fetch = jest.fn().mockResolvedValue({
1064
+ ok: true,
1065
+ json: async () => ({
1066
+ topics: TOPICS,
1067
+ archived_topics: [],
1068
+ can_manage: true,
1069
+ last_topic_id: '1',
1070
+ main_topic_id: '1',
1071
+ effective_creative_id: '42',
1072
+ }),
1073
+ })
1074
+ await controller.onPopupOpened({ creativeId: '42' })
1075
+
1076
+ expect(controller.currentTopicId).toBe('2')
1077
+ resolveSave(true)
1078
+ await save
1079
+ })
1080
+
1081
+ test('a new creative records its own snapshot baseline before its first save', async () => {
1082
+ let resolveSnapshot
1083
+ controller.lastKnownRemoteTopicId = '3'
1084
+ controller.creativeIdValue = '99'
1085
+ controller._renderedCreativeId = '99'
1086
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveSnapshot = resolve }))
1087
+
1088
+ const load = controller.loadTopics()
1089
+ controller.selectTopic('2')
1090
+ resolveSnapshot({
1091
+ ok: true,
1092
+ json: async () => ({
1093
+ topics: TOPICS,
1094
+ archived_topics: [],
1095
+ can_manage: true,
1096
+ last_topic_id: '1',
1097
+ main_topic_id: '1',
1098
+ effective_creative_id: '99',
1099
+ }),
1100
+ })
1101
+ await load
1102
+
1103
+ expect(controller.lastKnownRemoteTopicId).toBe('1')
1104
+ await controller.flushSaveLastTopic('2')
1105
+ expect(controller.pendingSelfEchoPreviousTopicIds.get(clientIdFor('2'))).toBe('1')
1106
+ })
1107
+
1108
+ test('a queued save uses its preceding save as the stream baseline after another creative loads', async () => {
1109
+ let resolveFirstSave
1110
+ let resolveSecondSave
1111
+ controller.lastKnownRemoteTopicId = '1'
1112
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstSave = resolve }))
1113
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSecondSave = resolve }))
1114
+
1115
+ const firstSave = controller.flushSaveLastTopic('2')
1116
+ await Promise.resolve()
1117
+ const queuedSave = controller.flushSaveLastTopic('3')
1118
+
1119
+ global.fetch = jest.fn().mockResolvedValue({
1120
+ ok: true,
1121
+ json: async () => ({
1122
+ topics: TOPICS,
1123
+ archived_topics: [],
1124
+ can_manage: true,
1125
+ last_topic_id: '3',
1126
+ main_topic_id: '1',
1127
+ effective_creative_id: '99',
1128
+ }),
1129
+ })
1130
+ await controller.onPopupOpened({ creativeId: '99' })
1131
+
1132
+ resolveFirstSave(true)
1133
+ await firstSave
1134
+ await Promise.resolve()
1135
+
1136
+ expect(controller.pendingSelfEchoPreviousTopicIds.get(clientIdFor('3'))).toBe('2')
1137
+ resolveSecondSave(true)
1138
+ await queuedSave
1139
+ })
1140
+
1141
+ test('a temporary creative switch keeps an in-flight save claim for its returning stream', async () => {
1142
+ let resolveSave
1143
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1144
+
1145
+ const save = controller.flushSaveLastTopic('2')
1146
+ await Promise.resolve()
1147
+ const clientId = clientIdFor('2')
1148
+
1149
+ global.fetch = jest.fn()
1150
+ .mockResolvedValueOnce({
1151
+ ok: true,
1152
+ json: async () => ({
1153
+ topics: TOPICS,
1154
+ archived_topics: [],
1155
+ can_manage: true,
1156
+ last_topic_id: '1',
1157
+ main_topic_id: '1',
1158
+ effective_creative_id: '99',
1159
+ }),
1160
+ })
1161
+ .mockResolvedValueOnce({
1162
+ ok: true,
1163
+ json: async () => ({
1164
+ topics: TOPICS,
1165
+ archived_topics: [],
1166
+ can_manage: true,
1167
+ last_topic_id: '1',
1168
+ main_topic_id: '1',
1169
+ effective_creative_id: '42',
1170
+ }),
1171
+ })
1172
+
1173
+ await controller.onPopupOpened({ creativeId: '99' })
1174
+ await controller.onPopupOpened({ creativeId: '42' })
1175
+ controller.selectTopic('3')
1176
+
1177
+ selfEcho('2')
1178
+
1179
+ expect(controller.pendingSelfEchoes).not.toContain(clientId)
1180
+ expect(controller.currentTopicId).toBe('3')
1181
+ resolveSave(true)
1182
+ await save
1183
+ })
1184
+
1185
+ test('a reopen keeps a newer server preference after a closed save completed', async () => {
1186
+ let resolveSave
1187
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1188
+ controller.selectTopic('2')
1189
+ const save = controller.flushSaveLastTopic('2')
1190
+ await Promise.resolve()
1191
+
1192
+ controller.onPopupClosed()
1193
+ resolveSave(true)
1194
+ await save
1195
+
1196
+ expect(controller.pendingSelfEchoes).toEqual([])
1197
+ global.fetch = jest.fn().mockResolvedValue({
1198
+ ok: true,
1199
+ json: async () => ({
1200
+ topics: TOPICS,
1201
+ archived_topics: [],
1202
+ can_manage: true,
1203
+ last_topic_id: '3',
1204
+ main_topic_id: '1',
1205
+ effective_creative_id: '42',
1206
+ }),
1207
+ })
1208
+ await controller.onPopupOpened({ creativeId: '42' })
1209
+
1210
+ expect(controller.currentTopicId).toBe('3')
1211
+ })
1212
+
1213
+ test('a reopened snapshot newer than a closed in-flight save is not overwritten', async () => {
1214
+ let resolveSave
1215
+ controller.lastKnownRemoteTopicId = '1'
1216
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1217
+ controller.selectTopic('2')
1218
+ const save = controller.flushSaveLastTopic('2')
1219
+ await Promise.resolve()
1220
+
1221
+ controller.onPopupClosed()
1222
+ global.fetch = jest.fn().mockResolvedValue({
1223
+ ok: true,
1224
+ json: async () => ({
1225
+ topics: TOPICS,
1226
+ archived_topics: [],
1227
+ can_manage: true,
1228
+ last_topic_id: '3',
1229
+ main_topic_id: '1',
1230
+ effective_creative_id: '42',
1231
+ }),
1232
+ })
1233
+ await controller.onPopupOpened({ creativeId: '42' })
1234
+
1235
+ expect(controller.currentTopicId).toBe('3')
1236
+ resolveSave(true)
1237
+ await save
1238
+ expect(controller.pendingSelfEchoes).toHaveLength(1)
1239
+ })
1240
+
1241
+ test('a queued save claimed after closing yields to a newer reopen snapshot', async () => {
1242
+ let resolveFirst
1243
+ let resolveSecond
1244
+ controller.lastKnownRemoteTopicId = '1'
1245
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
1246
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
1247
+
1248
+ controller.selectTopic('2')
1249
+ const first = controller.flushSaveLastTopic('2')
1250
+ await Promise.resolve()
1251
+ controller.selectTopic('3')
1252
+ const second = controller.flushSaveLastTopic('3')
1253
+
1254
+ controller.onPopupClosed()
1255
+ resolveFirst(true)
1256
+ await first
1257
+ await Promise.resolve()
1258
+
1259
+ const queuedClientId = clientIdFor('3')
1260
+ expect(controller.possiblyMissedPendingSelfEchoes).toContain(queuedClientId)
1261
+
1262
+ global.fetch = jest.fn().mockResolvedValue({
1263
+ ok: true,
1264
+ json: async () => ({
1265
+ topics: TOPICS,
1266
+ archived_topics: [],
1267
+ can_manage: true,
1268
+ last_topic_id: '1',
1269
+ main_topic_id: '1',
1270
+ effective_creative_id: '42',
1271
+ }),
1272
+ })
1273
+ await controller.onPopupOpened({ creativeId: '42' })
1274
+
1275
+ expect(controller.currentTopicId).toBe('1')
1276
+ resolveSecond(true)
1277
+ await second
1278
+ })
1279
+
1280
+ test('a response after reopening keeps its claim until the delayed echo lands', async () => {
1281
+ let resolveSave
1282
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1283
+ controller.selectTopic('2')
1284
+ const save = controller.flushSaveLastTopic('2')
1285
+ await Promise.resolve()
1286
+
1287
+ controller.onPopupClosed()
1288
+ controller.loadTopics = jest.fn()
1289
+ controller.onPopupOpened({ creativeId: '42' })
1290
+ controller.selectTopic('3')
1291
+
1292
+ resolveSave(true)
1293
+ await save
1294
+ expect(controller.pendingSelfEchoes).toHaveLength(1)
1295
+
1296
+ selfEcho('2')
1297
+ expect(controller.currentTopicId).toBe('3')
1298
+ })
1299
+
1300
+ test('a closed later save keeps the previous locally committed preference', async () => {
1301
+ controller.lastKnownRemoteTopicId = '1'
1302
+ controller.selectTopic('2')
1303
+ await controller.flushSaveLastTopic('2')
1304
+ expect(controller.lastKnownRemoteTopicId).toBe('2')
1305
+ selfEcho('2')
1306
+
1307
+ let resolveSave
1308
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1309
+ controller.selectTopic('3')
1310
+ const save = controller.flushSaveLastTopic('3')
1311
+ await Promise.resolve()
1312
+
1313
+ controller.onPopupClosed()
1314
+ global.fetch = jest.fn().mockResolvedValue({
1315
+ ok: true,
1316
+ json: async () => ({
1317
+ topics: TOPICS,
1318
+ archived_topics: [],
1319
+ can_manage: true,
1320
+ last_topic_id: '2',
1321
+ main_topic_id: '1',
1322
+ effective_creative_id: '42',
1323
+ }),
1324
+ })
1325
+ await controller.onPopupOpened({ creativeId: '42' })
1326
+
1327
+ expect(controller.currentTopicId).toBe('3')
1328
+ resolveSave(true)
1329
+ await save
1330
+ })
1331
+
1332
+ test('reopening an origin after its linked shell keeps the shared stream claim', async () => {
1333
+ let resolveSave
1334
+ controller.creativeIdValue = '77'
1335
+ controller.element.dataset.effectiveCreativeId = '42'
1336
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1337
+ controller.selectTopic('2')
1338
+ const save = controller.flushSaveLastTopic('2')
1339
+ await Promise.resolve()
1340
+
1341
+ controller.onPopupClosed()
1342
+ global.fetch = jest.fn().mockResolvedValue({
1343
+ ok: true,
1344
+ json: async () => ({
1345
+ topics: TOPICS,
1346
+ archived_topics: [],
1347
+ can_manage: true,
1348
+ last_topic_id: '2',
1349
+ main_topic_id: '1',
1350
+ effective_creative_id: '42',
1351
+ }),
1352
+ })
1353
+ await controller.onPopupOpened({ creativeId: '42' })
1354
+ controller.selectTopic('3')
1355
+ selfEcho('2')
1356
+
1357
+ expect(controller.currentTopicId).toBe('3')
1358
+ resolveSave(true)
1359
+ await save
1360
+ })
1361
+
1362
+ test('direct navigation from a linked shell to its origin keeps the shared stream claim', async () => {
1363
+ controller.creativeIdValue = '77'
1364
+ controller.element.dataset.effectiveCreativeId = '42'
1365
+ controller.subscribe()
1366
+ controller.selectTopic('2')
1367
+ await controller.flushSaveLastTopic('2')
1368
+
1369
+ global.fetch = jest.fn().mockResolvedValue({
1370
+ ok: true,
1371
+ json: async () => ({
1372
+ topics: TOPICS,
1373
+ archived_topics: [],
1374
+ can_manage: true,
1375
+ last_topic_id: '2',
1376
+ main_topic_id: '1',
1377
+ effective_creative_id: '42',
1378
+ }),
1379
+ })
1380
+ await controller.onPopupOpened({ creativeId: '42' })
1381
+ controller.selectTopic('3')
1382
+ selfEcho('2')
1383
+
1384
+ expect(controller.currentTopicId).toBe('3')
1385
+ })
1386
+
1387
+ test('closing releases an acknowledged linked-shell claim before reopening', async () => {
1388
+ controller.creativeIdValue = '77'
1389
+ controller.element.dataset.effectiveCreativeId = '42'
1390
+ controller.selectTopic('2')
1391
+ await controller.flushSaveLastTopic('2')
1392
+ const alphaClientId = clientIdFor('2')
1393
+
1394
+ expect(controller.pendingSelfEchoes).toContain(alphaClientId)
1395
+ expect(controller.acknowledgedPendingSelfEchoes).toContain(alphaClientId)
1396
+
1397
+ controller.onPopupClosed()
1398
+
1399
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1400
+ expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
1401
+ expect(controller.pendingSelfEchoAcknowledgementVersions.has(alphaClientId)).toBe(false)
1402
+ expect(controller.pendingSelfEchoRemoteRevisions.has(alphaClientId)).toBe(false)
1403
+
1404
+ global.fetch = jest.fn().mockResolvedValue({
1405
+ ok: true,
1406
+ json: async () => ({
1407
+ topics: TOPICS,
1408
+ archived_topics: [],
1409
+ can_manage: true,
1410
+ last_topic_id: '2',
1411
+ main_topic_id: '1',
1412
+ effective_creative_id: '42',
1413
+ }),
1414
+ })
1415
+ await controller.onPopupOpened({ creativeId: '77' })
1416
+
1417
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1418
+ })
1419
+
1420
+ test('a reopened linked shell keeps an in-flight claim before its replacement load resolves', async () => {
1421
+ controller.creativeIdValue = '77'
1422
+ global.fetch = jest.fn().mockResolvedValueOnce({
1423
+ ok: true,
1424
+ json: async () => ({
1425
+ topics: TOPICS,
1426
+ archived_topics: [],
1427
+ can_manage: true,
1428
+ last_topic_id: '1',
1429
+ main_topic_id: '1',
1430
+ effective_creative_id: '42',
1431
+ }),
1432
+ })
1433
+ await controller.loadTopics()
1434
+
1435
+ let resolveSave
1436
+ let resolveReopen
1437
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1438
+ controller.selectTopic('2')
1439
+ const save = controller.flushSaveLastTopic('2')
1440
+ await Promise.resolve()
1441
+ const alphaClientId = clientIdFor('2')
1442
+
1443
+ controller.onPopupClosed()
1444
+ global.fetch.mockImplementationOnce(() => new Promise((resolve) => { resolveReopen = resolve }))
1445
+ const reopen = controller.onPopupOpened({ creativeId: '77' })
1446
+ await Promise.resolve()
1447
+
1448
+ resolveSave(true)
1449
+ await save
1450
+ expect(controller.pendingSelfEchoes).toContain(alphaClientId)
1451
+
1452
+ resolveReopen({
1453
+ ok: true,
1454
+ json: async () => ({
1455
+ topics: TOPICS,
1456
+ archived_topics: [],
1457
+ can_manage: true,
1458
+ last_topic_id: '2',
1459
+ main_topic_id: '1',
1460
+ effective_creative_id: '42',
1461
+ }),
1462
+ })
1463
+ await reopen
1464
+
1465
+ controller.selectTopic('3')
1466
+ selfEcho('2')
1467
+
1468
+ expect(controller.currentTopicId).toBe('3')
1469
+ })
1470
+
1471
+ test('keeps a claim while a first linked shell resolves its effective stream', async () => {
1472
+ let resolveSave
1473
+ let resolveOpen
1474
+ controller.creativeIdValue = '42'
1475
+ controller.element.dataset.effectiveCreativeId = '42'
1476
+ controller.subscribe()
1477
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1478
+ const save = controller.flushSaveLastTopic('2')
1479
+ await Promise.resolve()
1480
+ const alphaClientId = clientIdFor('2')
1481
+
1482
+ global.fetch = jest.fn(() => new Promise((resolve) => { resolveOpen = resolve }))
1483
+ const open = controller.onPopupOpened({ creativeId: '77' })
1484
+ await Promise.resolve()
1485
+ resolveSave(true)
1486
+ await save
1487
+
1488
+ expect(controller.pendingSelfEchoes).toContain(alphaClientId)
1489
+
1490
+ resolveOpen({
1491
+ ok: true,
1492
+ json: async () => ({
1493
+ topics: TOPICS,
1494
+ archived_topics: [],
1495
+ can_manage: true,
1496
+ last_topic_id: '2',
1497
+ main_topic_id: '1',
1498
+ effective_creative_id: '42',
1499
+ }),
1500
+ })
1501
+ await open
1502
+ controller.selectTopic('3')
1503
+ echo('2', alphaClientId)
1504
+
1505
+ expect(controller.currentTopicId).toBe('3')
1506
+ })
1507
+
1508
+ test('releases an acknowledged claim after a first shell resolves to another stream', async () => {
1509
+ controller.creativeIdValue = '42'
1510
+ controller.element.dataset.effectiveCreativeId = '42'
1511
+ controller.subscribe()
1512
+ await controller.flushSaveLastTopic('2')
1513
+ const alphaClientId = clientIdFor('2')
1514
+
1515
+ global.fetch = jest.fn().mockResolvedValue({
1516
+ ok: true,
1517
+ json: async () => ({
1518
+ topics: TOPICS,
1519
+ archived_topics: [],
1520
+ can_manage: true,
1521
+ last_topic_id: '1',
1522
+ main_topic_id: '1',
1523
+ effective_creative_id: '88',
1524
+ }),
1525
+ })
1526
+ await controller.onPopupOpened({ creativeId: '77' })
1527
+
1528
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1529
+ })
1530
+
1531
+ test('a pre-resolution linked-shell save keeps its claim after resolving its origin', async () => {
1532
+ controller.creativeIdValue = '77'
1533
+ delete controller.element.dataset.effectiveCreativeId
1534
+ controller.selectTopic('2')
1535
+ await controller.flushSaveLastTopic('2')
1536
+
1537
+ global.fetch = jest.fn().mockResolvedValue({
1538
+ ok: true,
1539
+ json: async () => ({
1540
+ topics: TOPICS,
1541
+ archived_topics: [],
1542
+ can_manage: true,
1543
+ last_topic_id: '2',
1544
+ main_topic_id: '1',
1545
+ effective_creative_id: '42',
1546
+ }),
1547
+ })
1548
+ await controller.loadTopics()
1549
+
1550
+ expect(controller.pendingSelfEchoCreativeIds.get(clientIdFor('2'))).toBe('42')
1551
+ controller.selectTopic('3')
1552
+ selfEcho('2')
1553
+
1554
+ expect(controller.currentTopicId).toBe('3')
1555
+ })
1556
+
1557
+ test('a debounced linked-shell save retains its resolved stream after close and sibling reopen', async () => {
1558
+ let resolveSave
1559
+ jest.useFakeTimers()
1560
+ try {
1561
+ controller.creativeIdValue = '77'
1562
+ controller.element.dataset.effectiveCreativeId = '42'
1563
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1564
+
1565
+ controller.selectTopic('2')
1566
+ controller.onPopupClosed()
1567
+ controller.loadTopics = jest.fn()
1568
+ controller.onPopupOpened({ creativeId: '88' })
1569
+ await jest.advanceTimersByTimeAsync(500)
1570
+
1571
+ const oldSaveClientId = clientIdFor('2')
1572
+ expect(saveLastTopic).toHaveBeenCalledWith('77', '2', oldSaveClientId, expect.anything())
1573
+ expect(controller.pendingSelfEchoCreativeIds.get(oldSaveClientId)).toBe('42')
1574
+
1575
+ controller.selectTopic('3')
1576
+ echo('2', oldSaveClientId)
1577
+
1578
+ expect(controller.currentTopicId).toBe('3')
1579
+ resolveSave(true)
1580
+ } finally {
1581
+ jest.useRealTimers()
1582
+ }
1583
+ })
1584
+
1585
+ test('a legacy migration echo is correlated with a later pick', async () => {
1586
+ localStorage.setItem('collavre_creative_42_last_topic', '2')
1587
+ controller.serverLastTopicId = ''
1588
+
1589
+ controller.migrateLocalStorage()
1590
+ await Promise.resolve()
1591
+ await Promise.resolve()
1592
+ const migrationClientId = clientIdFor('2')
1593
+ expect(migrationClientId).toEqual(expect.any(String))
1594
+
1595
+ controller.selectTopic('3')
1596
+ echo('2', migrationClientId)
1597
+
1598
+ expect(controller.currentTopicId).toBe('3')
1599
+ })
1600
+
1601
+ test('reopening a different creative retains unresolved claims from the one left', async () => {
1602
+ let resolveSave
1603
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1604
+ controller.selectTopic('2')
1605
+ const save = controller.flushSaveLastTopic('2')
1606
+ await Promise.resolve()
1607
+
1608
+ controller.onPopupClosed()
1609
+ global.fetch = jest.fn().mockResolvedValue({
1610
+ ok: true,
1611
+ json: async () => ({
1612
+ topics: TOPICS,
1613
+ archived_topics: [],
1614
+ can_manage: true,
1615
+ last_topic_id: '1',
1616
+ main_topic_id: '1',
1617
+ effective_creative_id: '99',
1618
+ }),
1619
+ })
1620
+ await controller.onPopupOpened({ creativeId: '99' })
1621
+
1622
+ expect(controller.pendingSelfEchoes).toHaveLength(1)
1623
+ resolveSave(true)
1624
+ await save
1625
+ })
1626
+
1627
+ test('a completed save releases its claim after navigation subscribes to another stream', async () => {
1628
+ let resolveSave
1629
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1630
+ global.fetch = jest.fn().mockResolvedValue({
1631
+ ok: true,
1632
+ json: async () => ({
1633
+ topics: TOPICS,
1634
+ archived_topics: [],
1635
+ can_manage: true,
1636
+ last_topic_id: '',
1637
+ main_topic_id: '1',
1638
+ effective_creative_id: '99',
1639
+ }),
1640
+ })
1641
+
1642
+ const save = controller.flushSaveLastTopic('2')
1643
+ await Promise.resolve()
1644
+ const alphaClientId = clientIdFor('2')
1645
+
1646
+ const openDifferentCreative = controller.onPopupOpened({ creativeId: '99' })
1647
+ resolveSave(true)
1648
+ await Promise.all([save, openDifferentCreative])
1649
+
1650
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1651
+ expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
1652
+ })
1653
+
1654
+ // unsubscribe() is the deliberate exit, and a refused subscription cannot
1655
+ // reconnect, so both discard their claims. A dropped connection can miss a
1656
+ // broadcast, so acknowledged claims become ignore-only tombstones instead.
1657
+ describe('claims against a subscription that goes away on its own', () => {
1658
+ // Delivered the way the real ones are, through the subscription itself.
1659
+ const deliver = (lastTopicId, clientId) => subscriptionCallbacks.received({
1660
+ action: 'last_topic_changed',
1661
+ last_topic_id: lastTopicId,
1662
+ ...(clientId === undefined ? {} : { client_id: clientId }),
1663
+ })
1664
+
1665
+ beforeEach(() => {
1666
+ subscriptionCallbacks = undefined
1667
+ controller.subscribe()
1668
+ })
1669
+
1670
+ const dropAndReconnect = () => subscriptionCallbacks.disconnected()
1671
+
1672
+ test('a reconnectable disconnect keeps an in-flight claim for its delayed echo', async () => {
1673
+ let resolveSave
1674
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1675
+ const save = controller.flushSaveLastTopic('2')
1676
+ await Promise.resolve()
1677
+
1678
+ dropAndReconnect()
1679
+
1680
+ expect(controller.pendingSelfEchoes).toEqual([clientIdFor('2')])
1681
+ // Reconnected, and the user has since picked Beta. The delayed echo of
1682
+ // the request made before the reconnect is still ours, not new state.
1683
+ controller.selectTopic('3')
1684
+ deliver('2', clientIdFor('2'))
1685
+ resolveSave(true)
1686
+ await save
1687
+ expect(controller.currentTopicId).toBe('3')
1688
+ })
1689
+
1690
+ test('retires an acknowledged claim when a disconnect can lose its echo', async () => {
1691
+ let resolveSave
1692
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveSave = resolve }))
1693
+ const save = controller.flushSaveLastTopic('2')
1694
+ await Promise.resolve()
1695
+ const alphaClientId = clientIdFor('2')
1696
+
1697
+ dropAndReconnect()
1698
+ resolveSave(true)
1699
+ await save
1700
+
1701
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1702
+ expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
1703
+ expect(controller.isRetiredSelfEcho(alphaClientId)).toBe(true)
1704
+ controller.selectTopic('3')
1705
+ deliver('2', alphaClientId)
1706
+ expect(controller.currentTopicId).toBe('3')
1707
+ })
1708
+
1709
+ test('retires a claim already acknowledged when the connection drops', async () => {
1710
+ await controller.flushSaveLastTopic('2')
1711
+ const alphaClientId = clientIdFor('2')
1712
+
1713
+ dropAndReconnect()
1714
+
1715
+ expect(controller.pendingSelfEchoes).not.toContain(alphaClientId)
1716
+ expect(controller.pendingSelfEchoCreativeIds.has(alphaClientId)).toBe(false)
1717
+ expect(controller.isRetiredSelfEcho(alphaClientId)).toBe(true)
1718
+ })
1719
+
1720
+ test('a real update still applies after a reconnectable disconnect', async () => {
1721
+ controller.selectTopic('2')
1722
+ await controller.flushSaveLastTopic('2')
1723
+
1724
+ dropAndReconnect()
1725
+
1726
+ // Reconnected, and another session moves the preference to Alpha.
1727
+ controller.selectTopic('3')
1728
+ deliver('2')
1729
+ expect(controller.currentTopicId).toBe('2')
1730
+ })
1731
+
1732
+ // The same reasoning one step along the queue. A save waiting its turn is
1733
+ // sent after the consumer has reconnected, so its echo is not lost at all
1734
+ // — refusing it a claim on the strength of the disconnect would let its
1735
+ // own echo revert whatever the user picked while it was queued.
1736
+ test('a save queued across a reconnect still claims its echo', async () => {
1737
+ let resolveFirst
1738
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
1739
+ const first = controller.flushSaveLastTopic('2')
1740
+ const second = controller.flushSaveLastTopic('3')
1741
+ await Promise.resolve()
1742
+
1743
+ dropAndReconnect()
1744
+ resolveFirst(true)
1745
+ await Promise.all([first, second])
1746
+
1747
+ controller.selectTopic('')
1748
+ deliver('3', clientIdFor('3'))
1749
+
1750
+ expect(controller.currentTopicId).toBe('')
1751
+ })
1752
+
1753
+ test('a refused subscription drops outstanding claims', async () => {
1754
+ controller.selectTopic('2')
1755
+ await controller.flushSaveLastTopic('2')
1756
+
1757
+ subscriptionCallbacks.rejected()
1758
+
1759
+ expect(controller.pendingSelfEchoes).toHaveLength(0)
1760
+ controller.selectTopic('3')
1761
+ deliver('2')
1762
+ expect(controller.currentTopicId).toBe('2')
1763
+ })
1764
+
1765
+ test('a refused different-creative subscription retains the original stream claim', async () => {
1766
+ controller.selectTopic('2')
1767
+ await controller.flushSaveLastTopic('2')
1768
+ const alphaClientId = clientIdFor('2')
1769
+
1770
+ controller.creativeIdValue = '99'
1771
+ controller.subscribe({ preservePendingSelfEchoes: true })
1772
+ subscriptionCallbacks.rejected()
1773
+
1774
+ expect(controller.pendingSelfEchoes).toContain(alphaClientId)
1775
+ controller.creativeIdValue = '42'
1776
+ controller.subscribe({ preservePendingSelfEchoes: true })
1777
+ controller.selectTopic('3')
1778
+ deliver('2', alphaClientId)
1779
+
1780
+ expect(controller.currentTopicId).toBe('3')
1781
+ })
1782
+
1783
+ // The claim is only dropped by those two — an ordinary echo on a healthy
1784
+ // subscription still settles the save that is waiting for it.
1785
+ test('an echo on a live subscription is still consumed', async () => {
1786
+ controller.selectTopic('2')
1787
+ await controller.flushSaveLastTopic('2')
1788
+
1789
+ controller.selectTopic('3')
1790
+ deliver('2', clientIdFor('2'))
1791
+
1792
+ expect(controller.currentTopicId).toBe('3')
1793
+ })
1794
+ })
1795
+
1796
+ // Two PATCHes in flight at once are answered — and broadcast — in whatever
1797
+ // order the server finishes them, which the head-of-queue check reads as
1798
+ // "not ours". The claim it skips is then owed a message that has already
1799
+ // been and gone, and it swallows the next real update naming that topic.
1800
+ // Sending one save at a time is what makes the queue order true.
1801
+ describe('a save made while another is still in flight', () => {
1802
+ let resolveFirst
1803
+
1804
+ // Both picks are made back to back, as a fast second click does. The await
1805
+ // only lets the head of the queue issue its request — resolveFirst is the
1806
+ // handle on it, and is only assigned once it has.
1807
+ const startTwoSaves = async () => {
1808
+ resolveFirst = null
1809
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
1810
+ controller.selectTopic('2')
1811
+ const first = controller.flushSaveLastTopic('2')
1812
+ controller.selectTopic('3')
1813
+ const second = controller.flushSaveLastTopic('3')
1814
+ await Promise.resolve()
1815
+ expect(resolveFirst).toEqual(expect.any(Function))
1816
+ return [first, second]
1817
+ }
1818
+
1819
+ test('waits for the one in flight instead of racing it', async () => {
1820
+ const pending = await startTwoSaves()
1821
+
1822
+ expect(saveLastTopic).toHaveBeenCalledTimes(1)
1823
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '2', expect.any(String), expect.anything())
1824
+
1825
+ resolveFirst(true)
1826
+ await Promise.all(pending)
1827
+
1828
+ expect(saveLastTopic).toHaveBeenCalledTimes(2)
1829
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
1830
+ })
1831
+
1832
+ // With the sends ordered, the echoes are too: the first save's broadcast is
1833
+ // emitted before the second save is even sent. Both are then claimed and
1834
+ // consumed in turn, and nothing is left to swallow a later update.
1835
+ test('leaves no claim behind once both echoes land', async () => {
1836
+ const pending = await startTwoSaves()
1837
+ selfEcho('2')
1838
+ resolveFirst(true)
1839
+ await Promise.all(pending)
1840
+ selfEcho('3')
1841
+
1842
+ // The user moves on, and another session picks Beta for real.
1843
+ controller.selectTopic('')
1844
+ echo('3')
1845
+
1846
+ expect(controller.currentTopicId).toBe('3')
1847
+ })
1848
+
1849
+ // An ambiguous delivery still has to hand the queue on, or every later
1850
+ // pick is stranded behind it. It keeps its claim, though: the server can
1851
+ // have accepted the PATCH and broadcast after fetch reports a failure.
1852
+ test('an ambiguous save does not strand the one behind it', async () => {
1853
+ let rejectFirst
1854
+ saveLastTopic.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
1855
+ controller.selectTopic('2')
1856
+ const first = controller.flushSaveLastTopic('2')
1857
+ controller.selectTopic('3')
1858
+ const second = controller.flushSaveLastTopic('3')
1859
+ await Promise.resolve()
1860
+
1861
+ rejectFirst(new Error('network'))
1862
+ await Promise.all([first, second])
1863
+
1864
+ expect(saveLastTopic).toHaveBeenLastCalledWith('42', '3', expect.any(String), expect.anything())
1865
+ controller.selectTopic('')
1866
+ selfEcho('2')
1867
+ expect(controller.currentTopicId).toBe('')
1868
+ })
1869
+ })
1870
+
1871
+ // Topic equality is not sender identity. Two sessions of the same user can
1872
+ // save the same topic at the same time, and their broadcasts are then
1873
+ // indistinguishable by payload — so one claim gets settled by the wrong
1874
+ // message and the client's own echo comes back looking like news.
1875
+ describe('another session that named the same topic', () => {
1876
+ const raceOnAlpha = async () => {
1877
+ controller.selectTopic('2')
1878
+ await controller.flushSaveLastTopic('2')
1879
+
1880
+ // The other session had moved the preference to Alpha too, and its
1881
+ // broadcast is the first of the two to arrive.
1882
+ echo('2')
1883
+ // Ours has still not landed when the user picks Beta.
1884
+ controller.selectTopic('3')
1885
+ selfEcho('2')
1886
+ }
1887
+
1888
+ test('its broadcast does not settle a claim of ours', async () => {
1889
+ await raceOnAlpha()
1890
+
1891
+ expect(controller.currentTopicId).toBe('3')
1892
+ expect(changeEvents.at(-1).topicId).toBe('3')
1893
+ })
1894
+
1895
+ test('our own late echo is not recorded over the newer pick', async () => {
1896
+ await raceOnAlpha()
1897
+
1898
+ expect(controller.serverLastTopicId).toBe('3')
1899
+ })
1900
+ })
1901
+
1902
+ // crypto.randomUUID is only defined in a secure context. Keep the fallback
1903
+ // cryptographically random too, because these IDs distinguish a tab's own
1904
+ // Action Cable echoes from those emitted by sibling tabs.
1905
+ test('ids are still unique per save without crypto.randomUUID', async () => {
1906
+ Object.defineProperty(global.crypto, 'randomUUID', { value: undefined, configurable: true })
1907
+
1908
+ try {
1909
+ await controller.flushSaveLastTopic('2')
1910
+ await controller.flushSaveLastTopic('3')
1911
+ } finally {
1912
+ delete global.crypto.randomUUID
1913
+ }
1914
+
1915
+ const ids = saveLastTopic.mock.calls.map(c => c[2])
1916
+ expect(ids).toHaveLength(2)
1917
+ ids.forEach(id => expect(id).toMatch(/^save-/))
1918
+ expect(new Set(ids).size).toBe(2)
1919
+ })
1920
+
1921
+ // Claims are taken inside the queued callback, which runs whenever the save
1922
+ // ahead of it finishes — by then the subscription that would settle the claim
1923
+ // may already be gone, and clearing the queue at that moment cannot reach a
1924
+ // claim that does not exist yet.
1925
+ test('a save queued behind another leaves no claim once the stream is gone', async () => {
1926
+ let resolveFirst
1927
+ saveLastTopic.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
1928
+ const first = controller.flushSaveLastTopic('2')
1929
+ const second = controller.flushSaveLastTopic('3')
1930
+ await Promise.resolve()
1931
+
1932
+ controller.unsubscribe()
1933
+ resolveFirst(true)
1934
+ await Promise.all([first, second])
1935
+
1936
+ expect(controller.pendingSelfEchoes).toHaveLength(0)
1937
+ })
1938
+ })