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
@@ -5,35 +5,78 @@ import { alertDialog, confirmDialog } from "../../lib/utils/dialog"
5
5
 
6
6
  const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
7
7
  const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
8
+ const AMBIGUOUS_SAVE_CLAIM_TIMEOUT = 5_000
9
+ const SAVE_REQUEST_TIMEOUT = 5_000
10
+ const UNREAD_COUNT_REFRESH_DELAY = 250
11
+ const TOPIC_SCROLL_DURATION = 250
12
+ const LAST_TOPIC_SAVE_SESSION_STORAGE_KEY = "collavre:last-topic-save-session-id"
13
+ const LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY = "collavre:last-topic-save-sequence"
14
+ const LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX = "collavre:last-topic-save-session:"
15
+ let fallbackClientIdSequence = 0
16
+
17
+ // Names one save, so its broadcast can be told from a sibling session's. It
18
+ // has to be unique across the user's tabs, not just within this one: two tabs
19
+ // counting from zero would answer to each other's echoes.
20
+ function newClientId() {
21
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
22
+
23
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
24
+ const bytes = crypto.getRandomValues(new Uint8Array(16))
25
+ return `save-${Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')}`
26
+ }
27
+
28
+ fallbackClientIdSequence += 1
29
+ return `save-${Date.now().toString(36)}-${fallbackClientIdSequence.toString(36)}`
30
+ }
8
31
 
9
32
  export default class extends Controller {
10
- static targets = ["list"]
33
+ static targets = ["list", "creationContainer", "topicListButton"]
11
34
 
12
35
  connect() {
13
36
  this.topics = []
14
37
  this.canManageTopics = false
15
38
  this.canCreateTopic = false
39
+ this.canSetPrimaryAgent = false
16
40
  this.subscribedCreativeId = null
17
41
  this.topicsSubscription = null
18
- this._loadTopicsVersion = 0
42
+ this._loadTopicsVersion ||= 0
43
+ this._unreadCountsOverlay = null
44
+ this._topicScrollFrame = null
45
+ this._topicScrollInterrupted = false
46
+ this._topicScrollInterruptionVersion ||= 0
19
47
  // Initial load if creativeId is available (e.g. from dataset if set server-side)
20
- if (this.creativeId) {
48
+ if (this.creativeId && this.element.dataset.docked !== 'true') {
21
49
  this.loadTopics()
22
50
  this.subscribe()
23
51
  }
24
52
  this.handleNewMessage = this.handleNewMessage.bind(this)
25
53
  this.handleTopicMoved = this.handleTopicMoved.bind(this)
54
+ this.handleTopicListClose = this.handleTopicListClose.bind(this)
55
+ this.handleTopicScrollInput = this.handleTopicScrollInput.bind(this)
26
56
  window.addEventListener('comments--topics:new-message', this.handleNewMessage)
27
57
  window.addEventListener('collavre:topic-moved', this.handleTopicMoved)
58
+ this.element.addEventListener('topic-list:close', this.handleTopicListClose)
59
+ this.listTarget.addEventListener('wheel', this.handleTopicScrollInput, { passive: true })
60
+ this.listTarget.addEventListener('touchstart', this.handleTopicScrollInput, { passive: true })
28
61
  }
29
62
 
30
63
  disconnect() {
64
+ this.cancelProgrammaticScroll()
31
65
  window.removeEventListener('comments--topics:new-message', this.handleNewMessage)
32
66
  window.removeEventListener('collavre:topic-moved', this.handleTopicMoved)
67
+ this.element.removeEventListener('topic-list:close', this.handleTopicListClose)
68
+ this.listTarget.removeEventListener('wheel', this.handleTopicScrollInput)
69
+ this.listTarget.removeEventListener('touchstart', this.handleTopicScrollInput)
70
+ this._loadTopicsVersion += 1
71
+ this.cancelUnreadCountRefresh()
33
72
  this.unsubscribe()
34
73
  }
35
74
 
36
75
  onPopupOpened({ creativeId }) {
76
+ this.cancelProgrammaticScroll()
77
+ this._topicScrollInterrupted = false
78
+ this._popupClosed = false
79
+ const previousCreativeId = this.creativeIdValue
37
80
  this.creativeIdValue = creativeId
38
81
  // Clear stale cached state from the previous creative — otherwise
39
82
  // chat-context autofill (command_menu) reads stale values during the
@@ -44,13 +87,105 @@ export default class extends Controller {
44
87
  // effective_creative_id. loadTopics() repopulates both.
45
88
  delete this.element.dataset.effectiveCreativeId
46
89
  this.mainTopicId = null
47
- this.subscribe()
90
+ // Switching creatives reuses this instance without an onPopupClosed
91
+ // (popup_controller._navigateToEntry calls open()/openForCreative()
92
+ // again), so unread marks from the previous creative would badge the new
93
+ // creative's archived toggle — and never clear, since those ids are not
94
+ // among its topics. Only on an actual switch, though: a docked chat
95
+ // re-opens the *same* creative when a workspace-sync event carries a
96
+ // highlightId (popup_controller.handleCreativeClick), and these marks are
97
+ // transient — loadTopics() cannot rebuild them.
98
+ if (String(previousCreativeId || '') !== String(creativeId || '')) {
99
+ this.archivedWithNewMessages.clear()
100
+ // Scoped to the creative it was archived in; a topic id from another
101
+ // creative must not veto this one's restored selection.
102
+ this.archivedAwayTopicId = null
103
+ }
104
+ // Do not prune retained claims until loadTopics() has resolved this
105
+ // requested creative to its effective stream id. A linked shell and
106
+ // its origin use the same TopicsChannel stream, but only the response
107
+ // tells us that after this cached value has been cleared.
108
+ // Direct history navigation replaces the subscription without firing
109
+ // onPopupClosed(). Keep claims until loadTopics() can resolve whether
110
+ // this requested creative shares the previous effective stream.
111
+ this.subscribe({ preservePendingSelfEchoes: true })
48
112
  return this.loadTopics()
49
113
  }
50
114
 
51
115
  onPopupClosed() {
52
- this.unsubscribe()
116
+ this.cancelProgrammaticScroll()
117
+ this._popupClosed = true
118
+ this.releaseAcknowledgedPendingSelfEchoes()
119
+ this.markPendingSelfEchoesAsPossiblyMissed()
120
+ this._loadTopicsVersion += 1
121
+ this.cancelUnreadCountRefresh()
122
+ // The same creative can reopen before an in-flight save broadcasts.
123
+ // Keep that save's claim so its delayed echo remains recognisable on
124
+ // the replacement subscription; a different creative clears it when
125
+ // it opens.
126
+ this.unsubscribe({ preservePendingSelfEchoes: true })
53
127
  this.creativeIdValue = null
128
+ this.topics = []
129
+ this.mainTopicId = null
130
+ this.archivedWithNewMessages.clear()
131
+ this.archivedAwayTopicId = null
132
+ delete this.element.dataset.effectiveCreativeId
133
+ }
134
+
135
+ // Archived topics still accept messages, but their chips only exist in the
136
+ // DOM while the archived section is expanded. Remember which ones have
137
+ // unread traffic so the badge can live on the collapsed toggle and survive
138
+ // re-renders. Created lazily: a docked controller can receive popup
139
+ // lifecycle callbacks before connect() has run.
140
+ get archivedWithNewMessages() {
141
+ if (!this._archivedWithNewMessages) this._archivedWithNewMessages = new Set()
142
+ return this._archivedWithNewMessages
143
+ }
144
+
145
+ // list_controller asks this before appending a live message to All Messages:
146
+ // CommentsController#index leaves archived-topic comments out of that view,
147
+ // so a stream must not put one there either.
148
+ isArchivedTopic(id) {
149
+ if (!id) return false
150
+ return (this.archivedTopics || []).some(t => String(t.id) === String(id))
151
+ }
152
+
153
+ // Move a topic between the live and archived caches without waiting for the
154
+ // reload. Every caller follows with loadTopics(), which clears this.topics
155
+ // synchronously and refills both lists when its fetch lands — so this only
156
+ // has to be right for the window in between, which is exactly the window
157
+ // isArchivedTopic is consulted in.
158
+ applyArchiveTransition(action, topic) {
159
+ const id = topic && topic.id
160
+ if (!id) return
161
+
162
+ const key = String(id)
163
+ const known = [ ...(this.topics || []), ...(this.archivedTopics || []) ]
164
+ .find(t => String(t.id) === key)
165
+ const entry = { ...(known || {}), ...topic }
166
+
167
+ this.topics = (this.topics || []).filter(t => String(t.id) !== key)
168
+ this.archivedTopics = (this.archivedTopics || []).filter(t => String(t.id) !== key)
169
+
170
+ if (action === "archived") {
171
+ this.archivedTopics = [ ...this.archivedTopics, entry ]
172
+ } else {
173
+ this.topics = [ ...this.topics, entry ]
174
+ // Nothing archived is left to clear the badge from, so drop it here
175
+ // rather than leaving the toggle lit until the reload lands.
176
+ this.pruneArchivedBadges()
177
+ }
178
+ }
179
+
180
+ // The toggle badge is derived from set size, so an id that is no longer
181
+ // archived — unarchived, deleted, or left behind by a creative switch —
182
+ // would keep it lit with no chip to click and clear it.
183
+ pruneArchivedBadges() {
184
+ if (this.archivedWithNewMessages.size === 0) return
185
+ const archivedIds = new Set((this.archivedTopics || []).map(t => String(t.id)))
186
+ this.archivedWithNewMessages.forEach(id => {
187
+ if (!archivedIds.has(id)) this.archivedWithNewMessages.delete(id)
188
+ })
54
189
  }
55
190
 
56
191
  get creativeId() {
@@ -69,16 +204,42 @@ export default class extends Controller {
69
204
  return match ? match[1] : null
70
205
  }
71
206
 
207
+ // TopicsChannel and update_last_topic both resolve linked shells through
208
+ // effective_origin. Before a response provides that id, the requested id
209
+ // is the best available stream key. A shell that has already loaded keeps
210
+ // its resolved stream across a close/reopen, so an acknowledgement arriving
211
+ // before the replacement load can still tell that its echo is receivable.
212
+ get effectiveCreativeId() {
213
+ return this.element.dataset.effectiveCreativeId || this.effectiveCreativeIdFor(this.creativeId)
214
+ }
215
+
216
+ // A pending pick records the shell rendered in the popup, while channel
217
+ // claims are keyed by the origin stream. Resolve both through the cached
218
+ // mapping before comparing them so a linked-shell replacement keeps a newer
219
+ // pick ahead of an orphaned echo from the previous controller.
220
+ effectiveCreativeIdFor(creativeId) {
221
+ return this.knownEffectiveCreativeIds.get(String(creativeId)) || creativeId
222
+ }
223
+
72
224
  async loadTopics() {
73
225
  if (!this.creativeId) return
74
226
 
75
227
  const version = ++this._loadTopicsVersion
228
+ this._unreadCountsOverlay = null
229
+ const selectionEpoch = this.selectionEpoch
230
+ // A response can carry a snapshot produced before a save, while that
231
+ // save's HTTP response reaches us before the snapshot is processed.
232
+ // Remember which local saves had been acknowledged when this load
233
+ // started so their retained claims can still outrank that older view.
234
+ const acknowledgedSaveVersion = this.saveAcknowledgementVersion
235
+ this.activeLoadAcknowledgementVersions.set(version, acknowledgedSaveVersion)
236
+ const creativeId = this.creativeId
76
237
  // Clear stale topics from previous creative to prevent name-based
77
238
  // dedupe in handleTopicMessage from blocking valid broadcasts
78
239
  this.topics = []
79
240
 
80
241
  try {
81
- const response = await fetch(`/creatives/${this.creativeId}/topics`)
242
+ const response = await fetch(`/creatives/${creativeId}/topics`)
82
243
  // Discard stale response if a newer loadTopics() call was made
83
244
  if (version !== this._loadTopicsVersion) return
84
245
 
@@ -87,51 +248,215 @@ export default class extends Controller {
87
248
  }
88
249
  if (response.ok) {
89
250
  const data = await response.json()
90
- const topics = Array.isArray(data) ? data : data.topics
251
+ const unreadCounts = this._unreadCountsOverlay?.loadVersion === version
252
+ ? this._unreadCountsOverlay.counts
253
+ : null
254
+ const topics = this.applyUnreadCounts(Array.isArray(data) ? data : data.topics, unreadCounts)
91
255
  const canManage = Array.isArray(data) ? false : data.can_manage
92
256
  const canCreateTopic = Array.isArray(data) ? false : (data.can_create_topic ?? canManage)
257
+ // Assigning an agent is authorized at :write, not :admin, so the
258
+ // release control must follow the same capability — otherwise a
259
+ // write collaborator can pin an agent by drag-and-drop and then
260
+ // has no way to undo it. Falls back to canManage for a server
261
+ // that predates the field.
262
+ const canSetPrimaryAgent = Array.isArray(data) ? false : (data.can_set_primary_agent ?? canManage)
93
263
  this.topics = topics
94
264
  this.canManageTopics = canManage
95
265
  this.canCreateTopic = canCreateTopic
96
- this.archivedTopics = data.archived_topics || []
97
- this.serverLastTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
266
+ this.canSetPrimaryAgent = canSetPrimaryAgent
267
+ this.archivedTopics = this.applyUnreadCounts(data.archived_topics || [], unreadCounts)
268
+ this.pruneArchivedBadges()
269
+ const effectiveCreativeId = data.effective_creative_id
270
+ ? String(data.effective_creative_id)
271
+ : String(this.creativeId)
272
+ this.knownEffectiveCreativeIds.set(String(creativeId), effectiveCreativeId)
273
+ const snapshotTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
274
+ const snapshotTopicRevision = this.normalizeLastTopicRevision(data.last_topic_revision)
275
+ this.element.dataset.effectiveCreativeId = effectiveCreativeId
276
+ this.remapPendingSelfEchoesForCreative(creativeId, effectiveCreativeId)
277
+ this.releaseAcknowledgedPendingSelfEchoesOutside(effectiveCreativeId)
278
+ const staleLastTopicSnapshot = !this.observeLastTopicRevision(
279
+ effectiveCreativeId,
280
+ snapshotTopicRevision
281
+ )
282
+ const deferLastTopicReconciliation = this.hasUnacknowledgedRevisionedSaveFor(
283
+ effectiveCreativeId,
284
+ snapshotTopicRevision
285
+ )
286
+ if (deferLastTopicReconciliation) {
287
+ this.deferredLastTopicReconciliations.add(effectiveCreativeId)
288
+ }
289
+ // Claims are keyed by their effective creative stream. Keep claims
290
+ // for a stream we have temporarily left: a save can still be in
291
+ // flight while the popup visits another creative, and returning before
292
+ // its echo arrives must still recognise that echo as our own.
293
+ // A topic picked while this fetch was in flight is newer intent
294
+ // than the answer coming back: last_topic_id still names the
295
+ // topic the user left, because the save for the pick is
296
+ // debounced and has not landed. Overwriting it here — and then
297
+ // restoring from it below — throws the click away, snapping the
298
+ // strip and the message list back to the previous topic.
299
+ // _loadTopicsVersion does not cover this: it only discards a
300
+ // response outrun by another loadTopics(), not by a selection.
301
+ //
302
+ // Only a pick about this creative, though. Switching creatives
303
+ // leaves the previous creative's chips on screen until this
304
+ // fetch lands, and a click on one of those is intent about the
305
+ // creative being left. Its id would survive as the preference,
306
+ // fail the lookup in restoreSelection(), drop the user on Main
307
+ // and then persist Main as the new creative's saved topic.
308
+ const skipLastTopicReconciliation = staleLastTopicSnapshot || deferLastTopicReconciliation
309
+ const pickWon = !skipLastTopicReconciliation && !this.pendingPickWasSupersededWhileClosed(
310
+ effectiveCreativeId,
311
+ snapshotTopicId,
312
+ snapshotTopicRevision
313
+ ) && this.pickOutranks(selectionEpoch, creativeId, topics, this.archivedTopics)
314
+ if (skipLastTopicReconciliation) {
315
+ // The GET has a server ordering token, but this save has neither
316
+ // its response revision nor its echo yet. Its predecessor topic is
317
+ // not an ordering token (an ABA write can repeat it), so preserve
318
+ // the current selection until the request settles and retry below.
319
+ // A snapshot older than an already observed revision is likewise
320
+ // display data only; it must not restore an older preference.
321
+ } else if (pickWon) {
322
+ // An interim restore can run while this fetch has the strip
323
+ // empty and replace serverLastTopicId with Main. The epoch
324
+ // belongs to the actual pick, so restore that picked value
325
+ // rather than treating the derived fallback as its value.
326
+ this.serverLastTopicId = this._pickTopicId
327
+ // The picked value has not necessarily reached the server yet.
328
+ // Keep the snapshot as the first known remote baseline so a
329
+ // claim created after this load records what its pending save
330
+ // actually supersedes. Never replace a newer Action Cable or
331
+ // acknowledged-save baseline with this older response.
332
+ if (this.lastKnownRemoteTopicIdFor(effectiveCreativeId) === undefined) {
333
+ this.setLastKnownRemoteTopicId(effectiveCreativeId, snapshotTopicId)
334
+ }
335
+ } else {
336
+ const pendingTopicId = this.latestPendingSelfEchoTopicIdFor(
337
+ effectiveCreativeId,
338
+ snapshotTopicId,
339
+ acknowledgedSaveVersion,
340
+ snapshotTopicRevision
341
+ )
342
+ if (pendingTopicId === undefined) {
343
+ this.serverLastTopicId = snapshotTopicId
344
+ // A retained claim proves this response was an older view of
345
+ // the preference. Do not let that stale snapshot roll back
346
+ // the baseline used by the next save's closed-reopen check.
347
+ this.setLastKnownRemoteTopicId(effectiveCreativeId, snapshotTopicId)
348
+ } else {
349
+ this.serverLastTopicId = pendingTopicId
350
+ }
351
+ }
352
+ // The archive guard only has to outlive the sources that still
353
+ // name the topic. Test the effective selection, not just the
354
+ // preference: an unchanged ?topic_id= outranks it in the getter,
355
+ // so keying on serverLastTopicId would drop the guard while the
356
+ // URL was still pointing at the archived topic. Once nothing
357
+ // names it, a later reselection is honoured normally.
358
+ if (this.archivedAwayTopicId && String(this.currentTopicId) !== this.archivedAwayTopicId) {
359
+ this.archivedAwayTopicId = null
360
+ }
98
361
  this.isInbox = !!data.is_inbox
99
362
  this.systemTopicId = data.system_topic_id ? String(data.system_topic_id) : null
100
363
  this.mainTopicId = data.main_topic_id ? String(data.main_topic_id) : null
101
364
  // Expose effective origin id so chat-context autofill (slash commands)
102
365
  // and any other consumer can target the same creative the server uses
103
366
  // (linked creatives resolve params[:creative_id] through effective_origin).
104
- if (data.effective_creative_id) {
105
- this.element.dataset.effectiveCreativeId = String(data.effective_creative_id)
367
+ // Migrate localStorage to server if server has no value. An
368
+ // unresolved revisioned save must not turn its retained local
369
+ // selection into another PATCH while reconciliation is deferred.
370
+ if (!skipLastTopicReconciliation) {
371
+ this.migrateLocalStorage({ keepEmptyPick: pickWon })
106
372
  }
107
373
 
108
- // Migrate localStorage to server if server has no value
109
- this.migrateLocalStorage()
110
-
111
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
112
- this.restoreSelection()
374
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent, creativeId)
375
+ if (this.currentTopicId) this.clearNewMessageBadge(this.currentTopicId)
376
+ if (!skipLastTopicReconciliation) {
377
+ this.restoreSelection({ keepEmptyPick: pickWon })
378
+ }
379
+ this.refreshOpenTopicListPopup()
113
380
  }
114
381
  } catch (e) {
115
382
  console.error("Failed to load topics", e)
116
383
  throw e
384
+ } finally {
385
+ this.activeLoadAcknowledgementVersions.delete(version)
386
+ this.discardSettledSelfEchoesNoLongerNeeded()
117
387
  }
118
388
  }
119
389
 
120
- restoreSelection() {
390
+ // keepEmptyPick: the caller established that the user picked All Messages
391
+ // after this render was set in motion. That pick names no topic, so it
392
+ // cannot be restored from a topic list — it must be reapplied. Without
393
+ // this the Main fallback below would treat it as "nothing selected",
394
+ // navigate away from it and persist Main, which is the same revert a chip
395
+ // click suffers. A prior interim restore may also have dispatched Main, so
396
+ // reapplying sends the authoritative empty selection to downstream
397
+ // controllers again.
398
+ restoreSelection({ keepEmptyPick = false } = {}) {
121
399
  const lastTopicId = this.currentTopicId
122
- if (lastTopicId) {
123
- // Validate it exists in list
124
- const exists = this.listTarget.querySelector(`[data-id="${lastTopicId}"]`)
125
- if (exists) {
126
- this.selectTopic(lastTopicId)
400
+ // A deep link controls this popup's view, but it is not a replacement
401
+ // for the saved preference. Replaying the linked selection after a
402
+ // preference broadcast must therefore update the UI without writing the
403
+ // link back over that newer server value.
404
+ const preservePreference = this.hasDeepLinkSelection && !keepEmptyPick
405
+ // archiveTopic() switched away from this topic on purpose. The server
406
+ // preference still names it until the debounced save lands, so accept
407
+ // the local intent over the stale server answer for that window.
408
+ if (lastTopicId && String(lastTopicId) !== this.archivedAwayTopicId) {
409
+ // Validate against the topic data, not the rendered DOM. An archived
410
+ // topic is still readable and writable — archiving hides it from the
411
+ // strip, it does not close the conversation. A DOM lookup would bounce
412
+ // the user back to Main every time the archived section is collapsed,
413
+ // because collapsed chips are never rendered. selectTopic expands the
414
+ // section when the selection lands inside it.
415
+ const known = [ ...(this.topics || []), ...(this.archivedTopics || []) ]
416
+ if (known.some(t => String(t.id) === String(lastTopicId))) {
417
+ // Restoring is not a navigation, so it must not undo a collapse
418
+ // the user chose while sitting in this very topic. Only
419
+ // restoreSelection is suppressed; every other selectTopic caller
420
+ // is the user going somewhere, and reveals normally.
421
+ const reveal = this.archivedCollapsedTopicId !== String(lastTopicId)
422
+ this.selectTopic(lastTopicId, { reveal, pick: false, persist: !preservePreference })
127
423
  return
128
424
  }
129
425
  }
130
426
 
131
- this.selectTopic(this.mainTopicId || "")
427
+ if (keepEmptyPick && !lastTopicId) {
428
+ this.selectTopic("", { pick: false })
429
+ return
430
+ }
431
+
432
+ if (preservePreference && !lastTopicId) {
433
+ this.selectTopic("", { pick: false, persist: false })
434
+ return
435
+ }
436
+
437
+ // Not a pick: this fallback is what the current state resolves to, and
438
+ // loadTopics() empties the strip for the length of its fetch, so any
439
+ // re-render landing in that window resolves to Main whatever the user
440
+ // has selected. Counting it as intent would let it outrank the answer
441
+ // it was derived from — and drop the deep link on the way.
442
+ this.selectTopic(this.mainTopicId || "", { pick: false, persist: !preservePreference })
443
+ }
444
+
445
+ // An archived topic can be opened from the topic strip, the topic-list popup,
446
+ // a deep link, or a restored last-topic. Expand the archived section from this
447
+ // one choke point so the strip always shows which topic is open.
448
+ revealArchivedTopic(id) {
449
+ if (!id || this.showingArchived) return
450
+ if (!this.isArchivedTopic(id)) return
451
+
452
+ // Reaching here is a deliberate navigation into the section, so an
453
+ // earlier collapse no longer describes what the user wants.
454
+ this.archivedCollapsedTopicId = null
455
+ this.showingArchived = true
456
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
132
457
  }
133
458
 
134
- renderTopics(topics, canManage = false, canCreateTopic = canManage) {
459
+ renderTopics(topics, canManage = false, canCreateTopic = canManage, canSetPrimaryAgent = canManage, sourceCreativeId = this._topicsCreativeId || this.creativeId) {
135
460
  const dragActions = canManage
136
461
  ? 'dragstart->comments--topics#handleTopicDragStart dragend->comments--topics#handleTopicDragEnd'
137
462
  : ''
@@ -150,15 +475,22 @@ export default class extends Controller {
150
475
  const renderTopic = (topic) => {
151
476
  const isActive = String(this.currentTopicId) === String(topic.id) ? 'active' : ''
152
477
  const draggable = canManage ? 'draggable="true"' : ''
478
+ // agent_locked marks a live agent session topic, whose primary agent is
479
+ // session identity rather than a routing pin — the server refuses to
480
+ // change it, so the avatar must not offer to release it.
481
+ const releasableTopicId = canSetPrimaryAgent && !topic.agent_locked ? topic.id : null
153
482
  const agentAvatar = topic.primary_agent
154
- ? this.renderAgentAvatar(topic.primary_agent)
483
+ ? this.renderAgentAvatar(topic.primary_agent, releasableTopicId)
155
484
  : ''
156
485
  const branchIcon = topic.source_topic_id ? '<span class="topic-branch-icon" title="Branched">↗</span>' : ''
486
+ const unreadBadge = topic.unread_count > 0
487
+ ? `<span class="topic-unread-badge">${topic.unread_count}</span>`
488
+ : ''
157
489
  const isMainTopic = this.mainTopicId && String(topic.id) === String(this.mainTopicId)
158
490
  let s = `<span class="topic-tag topic-drop-target ${isActive}" ${draggable}
159
491
  data-action="click->comments--topics#select ${dropActions} ${dragActions} ${topicDropActions}"
160
492
  data-id="${topic.id}"${topic.source_topic_id ? ` data-source-topic-id="${topic.source_topic_id}"` : ''}>
161
- ${agentAvatar}${branchIcon}#${topic.name}`
493
+ ${agentAvatar}${branchIcon}#${topic.name}${unreadBadge}`
162
494
  if (canManage && !isMainTopic) {
163
495
  s += `<button class="archive-topic-btn" data-action="click->comments--topics#archiveTopic" data-id="${topic.id}" title="Archive">${ICON_ARCHIVE}</button>`
164
496
  s += `<button class="delete-topic-btn" data-action="click->comments--topics#deleteTopic" data-id="${topic.id}">&times;</button>`
@@ -176,28 +508,64 @@ export default class extends Controller {
176
508
 
177
509
  // Archived topics section
178
510
  if (this.archivedTopics && this.archivedTopics.length > 0) {
179
- html += `<span class="topic-archived-toggle" data-action="click->comments--topics#toggleArchivedTopics">
511
+ // Badges are re-applied from state on every render because innerHTML
512
+ // below wipes the classes set by handleNewMessage.
513
+ const toggleBadge = this.archivedWithNewMessages.size > 0 ? ' has-new-messages' : ''
514
+ html += `<span class="topic-archived-toggle${toggleBadge}" data-action="click->comments--topics#toggleArchivedTopics">
180
515
  ${ICON_ARCHIVE} ${this.archivedTopics.length}
181
516
  </span>`
182
517
  if (this.showingArchived) {
183
518
  this.archivedTopics.forEach(topic => {
184
- html += `<span class="topic-tag topic-archived" data-id="${topic.id}">
185
- #${topic.name}
519
+ const isActive = String(this.currentTopicId) === String(topic.id) ? ' active' : ''
520
+ const hasNew = this.archivedWithNewMessages.has(String(topic.id)) ? ' has-new-messages' : ''
521
+ const unreadBadge = topic.unread_count > 0
522
+ ? `<span class="topic-unread-badge">${topic.unread_count}</span>`
523
+ : ''
524
+ html += `<span class="topic-tag topic-archived${isActive}${hasNew}"
525
+ data-action="click->comments--topics#select"
526
+ data-id="${topic.id}">
527
+ #${topic.name}${unreadBadge}
186
528
  ${canManage ? `<button class="unarchive-topic-btn" data-action="click->comments--topics#unarchiveTopic" data-id="${topic.id}" title="Restore">${ICON_RESTORE}</button>` : ''}
187
529
  </span>`
188
530
  })
189
531
  }
190
532
  }
191
533
 
192
- // Add create button container (write permission is sufficient for topic creation)
193
- if (canCreateTopic) {
194
- html += `<span class="topic-creation-container" data-comments--topics-target="creationContainer"
195
- data-action="dragover->comments--topics#handleAddButtonDragOver dragleave->comments--topics#handleDragLeave drop->comments--topics#handleAddButtonDrop">
196
- <button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>
197
- </span>`
534
+ this.listTarget.innerHTML = html
535
+ // Which creative the chips now on screen belong to. A click can only
536
+ // ever be about this one, whatever this.creativeId has since become.
537
+ this._renderedCreativeId = sourceCreativeId
538
+ this._topicsCreativeId = sourceCreativeId
539
+
540
+ // The create button lives outside the scrolling strip so it stays reachable
541
+ // without horizontal scrolling, no matter how many topics there are.
542
+ this.renderCreationContainer(canCreateTopic)
543
+ }
544
+
545
+ // Write permission is sufficient for topic creation.
546
+ renderCreationContainer(canCreateTopic) {
547
+ if (!this.hasCreationContainerTarget) return
548
+ const container = this.creationContainerTarget
549
+
550
+ container.hidden = !canCreateTopic
551
+ if (!canCreateTopic) {
552
+ container.innerHTML = ''
553
+ return
198
554
  }
199
555
 
200
- this.listTarget.innerHTML = html
556
+ // renderTopics re-runs on every topic broadcast; don't wipe a name being typed.
557
+ // `creating` marks an already-submitted name, whose input must give way to the button.
558
+ // A draft only survives re-renders of the creative it was typed for: chat-nav
559
+ // switches creatives without blurring, and posting it there is the wrong creative.
560
+ const draftIsCurrent = String(this._draftCreativeId) === String(this.creativeId)
561
+ if (!this.creating && draftIsCurrent && container.querySelector('.topic-input')) return
562
+
563
+ this.renderAddButton()
564
+ }
565
+
566
+ renderAddButton() {
567
+ this.creationContainerTarget.innerHTML =
568
+ `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
201
569
  }
202
570
 
203
571
  handleDragOver(event) {
@@ -345,7 +713,7 @@ export default class extends Controller {
345
713
 
346
714
  // Update local state and UI immediately
347
715
  this.topics = topics
348
- this.renderTopics(topics, this.canManageTopics, this.canCreateTopic)
716
+ this.renderTopics(topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
349
717
  this.restoreSelection()
350
718
 
351
719
  // Send to server
@@ -389,6 +757,9 @@ export default class extends Controller {
389
757
 
390
758
  if (!topicId) return
391
759
 
760
+ const deletingSelectedTopic = String(this.currentTopicId) === String(topicId)
761
+ const scrollInterruptionVersion = this._topicScrollInterruptionVersion
762
+
392
763
  try {
393
764
  const response = await fetch(`/creatives/${this.creativeId}/topics/${topicId}`, {
394
765
  method: 'DELETE',
@@ -398,13 +769,25 @@ export default class extends Controller {
398
769
  })
399
770
 
400
771
  if (response.ok) {
772
+ // The server can broadcast "deleted" before this response. In
773
+ // that case removeTopic() has already cleared currentTopicId,
774
+ // but this local removal should still reveal All Messages.
775
+ // A newer wheel/touch gesture always wins, though.
776
+ if (deletingSelectedTopic
777
+ && this._topicScrollInterruptionVersion === scrollInterruptionVersion) {
778
+ this._topicScrollInterrupted = false
779
+ }
401
780
  if (String(this.currentTopicId) === String(topicId)) {
781
+ // Same deep-link hazard as the "deleted" broadcast: this
782
+ // path reaches restoreSelection through loadTopics instead
783
+ // of removeTopic, but the getter is the same one.
784
+ this.releaseDeepLinkSelection(topicId)
402
785
  this.currentTopicId = "" // Switch to Main
403
786
  this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
404
787
  }
405
788
  this.loadTopics()
406
789
  } else {
407
- alertDialog("Failed to delete topic")
790
+ alertDialog(this._i18n("delete_error"))
408
791
  }
409
792
  } catch (e) {
410
793
  console.error("Error deleting topic", e)
@@ -426,12 +809,31 @@ export default class extends Controller {
426
809
 
427
810
  if (response.ok) {
428
811
  if (String(this.currentTopicId) === String(topicId)) {
812
+ this._topicScrollInterrupted = false
813
+ // Archiving the topic in view switches to All Messages, but
814
+ // the preference save is debounced 500ms and both this
815
+ // reload and the "archived" broadcast's own reload can beat
816
+ // it. restoreSelection() accepts archived ids now, so the
817
+ // stale last_topic_id would drop the user straight back into
818
+ // the topic they just archived out of. Flag it instead of
819
+ // flushing the save: the broadcast reload cannot be ordered
820
+ // behind the flush, only ignored.
821
+ this.archivedAwayTopicId = String(topicId)
822
+ // Both sources that outrank serverLastTopicId in the
823
+ // currentTopicId getter have to go too, or the archived
824
+ // topic stays the effective selection no matter what the
825
+ // preference says: overrideTopicId (deep link) and the
826
+ // ?topic_id= query parameter.
827
+ this.releaseDeepLinkSelection(topicId)
429
828
  this.currentTopicId = ""
430
829
  this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
431
830
  }
831
+ // The actor has the same stale-membership window as everyone
832
+ // receiving the broadcast, so close it on this side too.
833
+ this.applyArchiveTransition("archived", { id: topicId })
432
834
  this.loadTopics()
433
835
  } else {
434
- alertDialog("Failed to archive topic")
836
+ alertDialog(this._i18n("archive_error"))
435
837
  }
436
838
  } catch (e) {
437
839
  console.error("Error archiving topic", e)
@@ -452,9 +854,10 @@ export default class extends Controller {
452
854
  })
453
855
 
454
856
  if (response.ok) {
857
+ this.applyArchiveTransition("unarchived", { id: topicId })
455
858
  this.loadTopics()
456
859
  } else {
457
- alertDialog("Failed to restore topic")
860
+ alertDialog(this._i18n("restore_error"))
458
861
  }
459
862
  } catch (e) {
460
863
  console.error("Error restoring topic", e)
@@ -464,31 +867,47 @@ export default class extends Controller {
464
867
  toggleArchivedTopics(event) {
465
868
  event.stopPropagation()
466
869
  this.showingArchived = !this.showingArchived
467
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
468
- this.restoreSelection()
870
+ // Collapsing while the open topic lives in this section is a deliberate
871
+ // choice to hide it, so record it. Every re-render runs restoreSelection
872
+ // → selectTopic → revealArchivedTopic, which would otherwise reopen the
873
+ // section on any unrelated rename, reorder, or topic broadcast.
874
+ this.archivedCollapsedTopicId = !this.showingArchived && this.isArchivedTopic(this.currentTopicId)
875
+ ? String(this.currentTopicId)
876
+ : null
877
+ // No restoreSelection() here: toggling cannot invalidate the selection, and
878
+ // renderTopics already re-applies the active class. Re-selecting would
879
+ // re-expand the section the user just collapsed (selectTopic reveals an
880
+ // archived selection) and pointlessly reload the message list.
881
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
469
882
  }
470
883
 
471
884
  openTopicListPopup(event) {
885
+ if (this.topicListTogglePointerDown) {
886
+ this.cancelTopicListToggle()
887
+ return
888
+ }
889
+
472
890
  const btnRect = event.currentTarget.getBoundingClientRect()
473
891
 
474
892
  const openWith = (popup) => {
475
893
  popup.openForTopics(
476
- {
477
- topics: this.topics || [],
478
- archivedTopics: this.archivedTopics || [],
479
- mainTopicId: this.mainTopicId,
480
- allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
481
- },
894
+ this.topicListData(),
482
895
  btnRect,
483
- (item) => this.selectTopic(item.id),
896
+ (item) => this.selectTopic(item.id, { userInitiated: true }),
484
897
  this.element
485
898
  )
899
+ this.setTopicListButtonExpanded(true)
486
900
  }
487
901
 
488
902
  let modal = document.getElementById('topic-list-modal')
489
903
  if (modal) {
490
904
  const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
491
- if (popup) openWith(popup)
905
+ if (popup?.popup?.isOpen()) {
906
+ popup?.close()
907
+ this.setTopicListButtonExpanded(false)
908
+ } else if (popup) {
909
+ openWith(popup)
910
+ }
492
911
  return
493
912
  }
494
913
 
@@ -515,11 +934,164 @@ export default class extends Controller {
515
934
  })
516
935
  }
517
936
 
937
+ topicListData() {
938
+ return {
939
+ topics: this.topics || [],
940
+ archivedTopics: this.archivedTopics || [],
941
+ mainTopicId: this.mainTopicId,
942
+ allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
943
+ }
944
+ }
945
+
946
+ refreshOpenTopicListPopup() {
947
+ const modal = this.element.querySelector('#topic-list-modal')
948
+ const popup = modal && this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
949
+ if (popup?.popup?.isOpen()) popup.updateTopics(this.topicListData())
950
+ }
951
+
952
+ scheduleUnreadCountRefresh() {
953
+ if (this._unreadCountRefreshInFlight) {
954
+ this._unreadCountRefreshQueued = true
955
+ return
956
+ }
957
+ if (this._unreadCountRefreshTimer) return
958
+
959
+ this._unreadCountRefreshTimer = setTimeout(() => {
960
+ this._unreadCountRefreshTimer = null
961
+ this._unreadCountRefreshInFlight = true
962
+ this.refreshUnreadCounts()
963
+ .catch(error => console.error("Failed to refresh topic unread counts", error))
964
+ .finally(() => {
965
+ this._unreadCountRefreshInFlight = false
966
+ if (!this._unreadCountRefreshQueued) return
967
+
968
+ this._unreadCountRefreshQueued = false
969
+ this.scheduleUnreadCountRefresh()
970
+ })
971
+ }, UNREAD_COUNT_REFRESH_DELAY)
972
+ }
973
+
974
+ cancelUnreadCountRefresh() {
975
+ clearTimeout(this._unreadCountRefreshTimer)
976
+ this._unreadCountRefreshTimer = null
977
+ this._unreadCountRefreshQueued = false
978
+ }
979
+
980
+ async refreshUnreadCounts() {
981
+ if (!this.creativeId) return
982
+
983
+ const creativeId = String(this.creativeId)
984
+ const loadVersion = this._loadTopicsVersion
985
+ const response = await fetch(`/creatives/${creativeId}/topics`)
986
+ if (!response.ok) return
987
+
988
+ const data = await response.json()
989
+ if (String(this.creativeId) !== creativeId || this._loadTopicsVersion !== loadVersion) return
990
+
991
+ const activeTopics = Array.isArray(data) ? data : data.topics
992
+ const counts = new Map(
993
+ [...(activeTopics || []), ...(data.archived_topics || [])]
994
+ .map(topic => [String(topic.id), Number(topic.unread_count) || 0])
995
+ )
996
+ this._unreadCountsOverlay = { loadVersion, counts }
997
+ this.topics = this.applyUnreadCounts(this.topics, counts)
998
+ this.archivedTopics = this.applyUnreadCounts(this.archivedTopics, counts)
999
+ if (this.currentTopicId) this.clearNewMessageBadge(this.currentTopicId)
1000
+ this.refreshRenderedUnreadCounts()
1001
+ this.refreshOpenTopicListPopup()
1002
+ }
1003
+
1004
+ applyUnreadCounts(topics = [], counts = null) {
1005
+ if (!counts) return topics
1006
+
1007
+ return topics.map(topic => counts.has(String(topic.id))
1008
+ ? { ...topic, unread_count: counts.get(String(topic.id)) }
1009
+ : topic)
1010
+ }
1011
+
1012
+ refreshRenderedUnreadCounts() {
1013
+ const topicsById = new Map(
1014
+ [...(this.topics || []), ...(this.archivedTopics || [])]
1015
+ .map(topic => [String(topic.id), topic])
1016
+ )
1017
+
1018
+ this.listTarget.querySelectorAll('.topic-tag[data-id]').forEach(topicEl => {
1019
+ const topic = topicsById.get(String(topicEl.dataset.id))
1020
+ if (!topic || topicEl.querySelector('.topic-edit-input')) return
1021
+
1022
+ const count = Number(topic.unread_count) || 0
1023
+ const badge = topicEl.querySelector('.topic-unread-badge')
1024
+ if (count <= 0) {
1025
+ badge?.remove()
1026
+ return
1027
+ }
1028
+ if (badge) {
1029
+ badge.textContent = String(count)
1030
+ return
1031
+ }
1032
+
1033
+ const newBadge = document.createElement('span')
1034
+ newBadge.className = 'topic-unread-badge'
1035
+ newBadge.textContent = String(count)
1036
+ topicEl.insertBefore(newBadge, topicEl.querySelector('button'))
1037
+ })
1038
+ }
1039
+
1040
+ prepareTopicListToggle(event) {
1041
+ if (event.isPrimary === false || event.button !== 0) return
1042
+
1043
+ const modal = document.getElementById('topic-list-modal')
1044
+ const popup = modal && this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
1045
+ // Let every open popup receive this pointer event and perform its normal
1046
+ // outside-click cleanup. If this popup was one of them, consume the
1047
+ // following click so it does not immediately reopen.
1048
+ if (popup?.popup?.isOpen()) {
1049
+ this.topicListTogglePointerDown = true
1050
+ this.topicListTogglePointerId = event.pointerId
1051
+ event.currentTarget.setPointerCapture(event.pointerId)
1052
+ }
1053
+ }
1054
+
1055
+ finishTopicListToggle(event) {
1056
+ if (event.pointerId !== this.topicListTogglePointerId) return
1057
+
1058
+ const rect = event.currentTarget.getBoundingClientRect()
1059
+ const releasedOutsideButton = event.clientX < rect.left || event.clientX > rect.right ||
1060
+ event.clientY < rect.top || event.clientY > rect.bottom
1061
+ if (releasedOutsideButton) {
1062
+ this.cancelTopicListToggle(event)
1063
+ } else {
1064
+ // A completed activation dispatches click before the next task. Clear a
1065
+ // canceled in-button gesture afterwards so it cannot consume a later click.
1066
+ this.topicListToggleClearTimeout = setTimeout(() => this.cancelTopicListToggle(event), 0)
1067
+ }
1068
+ }
1069
+
1070
+ cancelTopicListToggle(event = {}) {
1071
+ if (event.pointerId != null && event.pointerId !== this.topicListTogglePointerId) return
1072
+
1073
+ clearTimeout(this.topicListToggleClearTimeout)
1074
+ this.topicListToggleClearTimeout = undefined
1075
+ this.topicListTogglePointerDown = false
1076
+ this.topicListTogglePointerId = undefined
1077
+ }
1078
+
1079
+ handleTopicListClose() {
1080
+ this.setTopicListButtonExpanded(false)
1081
+ }
1082
+
1083
+ setTopicListButtonExpanded(expanded) {
1084
+ if (this.hasTopicListButtonTarget) {
1085
+ this.topicListButtonTarget.setAttribute('aria-expanded', String(expanded))
1086
+ }
1087
+ }
1088
+
518
1089
  showInput(event) {
519
1090
  event.preventDefault()
520
- const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
521
- if (!container) return
1091
+ if (!this.hasCreationContainerTarget) return
1092
+ const container = this.creationContainerTarget
522
1093
 
1094
+ this._draftCreativeId = this.creativeId
523
1095
  const placeholder = this.listTarget.dataset.newTopicPlaceholder || "New Topic"
524
1096
  container.innerHTML = `<input type="text" class="topic-input" placeholder="${placeholder}"
525
1097
  data-action="keydown->comments--topics#handleInputKey blur->comments--topics#resetInput"
@@ -532,9 +1104,8 @@ export default class extends Controller {
532
1104
  resetInput() {
533
1105
  // Small delay to allow enter key to process first if that was the cause
534
1106
  setTimeout(() => {
535
- const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
536
- if (container && !this.creating) {
537
- container.innerHTML = `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
1107
+ if (this.hasCreationContainerTarget && !this.creating) {
1108
+ this.renderAddButton()
538
1109
  }
539
1110
  }, 200)
540
1111
  }
@@ -563,7 +1134,7 @@ export default class extends Controller {
563
1134
  if (event.target.closest('.topic-branch-icon')) {
564
1135
  const sourceTopicId = event.currentTarget.dataset.sourceTopicId
565
1136
  if (sourceTopicId) {
566
- this.selectTopic(sourceTopicId)
1137
+ this.selectTopic(sourceTopicId, { userInitiated: true })
567
1138
  return
568
1139
  }
569
1140
  }
@@ -576,11 +1147,18 @@ export default class extends Controller {
576
1147
  return
577
1148
  }
578
1149
 
579
- this.selectTopic(id)
1150
+ this.selectTopic(id, { userInitiated: true })
580
1151
  }
581
1152
 
582
- selectTopic(id) {
583
- this.updateSelectionUI(id)
1153
+ selectTopic(id, { reveal = true, pick = true, persist = true, userInitiated = false } = {}) {
1154
+ // Only restoreSelection() is guarded, so reaching selectTopic with this
1155
+ // id means the user deliberately went back into the archived topic.
1156
+ // The transition is over.
1157
+ if (this.archivedAwayTopicId && String(id) === this.archivedAwayTopicId) {
1158
+ this.archivedAwayTopicId = null
1159
+ }
1160
+ if (reveal) this.revealArchivedTopic(id)
1161
+ this.updateSelectionUI(id, { pick, persist, pending: pick, userInitiated })
584
1162
  if (id) {
585
1163
  this.clearNewMessageBadge(id)
586
1164
  }
@@ -634,6 +1212,7 @@ export default class extends Controller {
634
1212
  if (topicEl && topicEl.dataset.originalHtml) {
635
1213
  topicEl.innerHTML = topicEl.dataset.originalHtml
636
1214
  delete topicEl.dataset.originalHtml
1215
+ this.refreshRenderedUnreadCounts()
637
1216
  }
638
1217
 
639
1218
  setTimeout(() => { this.editCancelling = false }, 100)
@@ -658,10 +1237,10 @@ export default class extends Controller {
658
1237
  if (index !== -1) {
659
1238
  this.topics[index] = { ...this.topics[index], ...updatedTopic }
660
1239
  }
661
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
1240
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
662
1241
  this.restoreSelection()
663
1242
  } else {
664
- alertDialog("Failed to update topic")
1243
+ alertDialog(this._i18n("update_error"))
665
1244
  this.loadTopics() // Reload to restore state
666
1245
  }
667
1246
  } catch (e) {
@@ -670,8 +1249,9 @@ export default class extends Controller {
670
1249
  }
671
1250
  }
672
1251
 
673
- updateSelectionUI(id) {
674
- this.currentTopicId = id
1252
+ updateSelectionUI(id, { pick = true, persist = true, pending = false, userInitiated = false } = {}) {
1253
+ if (userInitiated) this._topicScrollInterrupted = false
1254
+ this.applySelection(id, { pick, persist, pending })
675
1255
  // Update UI
676
1256
  let activeEl = null
677
1257
  this.listTarget.querySelectorAll('.topic-tag').forEach(el => {
@@ -682,17 +1262,56 @@ export default class extends Controller {
682
1262
  activeEl = el
683
1263
  }
684
1264
  })
685
- // Scroll active topic into view
686
- if (activeEl) {
687
- activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
688
- }
1265
+ if (activeEl) this.scrollTopicIntoView(activeEl)
689
1266
  }
690
1267
 
691
1268
  scrollToActiveTopic() {
692
1269
  const activeEl = this.listTarget.querySelector('.topic-tag.active')
693
- if (activeEl) {
694
- activeEl.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
1270
+ if (activeEl) this.scrollTopicIntoView(activeEl)
1271
+ }
1272
+
1273
+ scrollTopicIntoView(topic) {
1274
+ if (this._popupClosed || this._topicScrollInterrupted) return
1275
+
1276
+ this.cancelProgrammaticScroll()
1277
+ const list = this.listTarget
1278
+ const listRect = list.getBoundingClientRect()
1279
+ const topicRect = topic.getBoundingClientRect()
1280
+ const maxLeft = Math.max(0, list.scrollWidth - list.clientWidth)
1281
+ const targetLeft = Math.max(0, Math.min(
1282
+ maxLeft,
1283
+ list.scrollLeft + topicRect.left + (topicRect.width / 2)
1284
+ - listRect.left - (listRect.width / 2),
1285
+ ))
1286
+ const startLeft = list.scrollLeft
1287
+ const distance = targetLeft - startLeft
1288
+ if (Math.abs(distance) < 1) return
1289
+
1290
+ const startedAt = performance.now()
1291
+ const step = now => {
1292
+ const progress = Math.min((now - startedAt) / TOPIC_SCROLL_DURATION, 1)
1293
+ const easedProgress = 1 - ((1 - progress) ** 3)
1294
+ list.scrollLeft = startLeft + (distance * easedProgress)
1295
+ if (progress < 1) {
1296
+ this._topicScrollFrame = requestAnimationFrame(step)
1297
+ } else {
1298
+ this._topicScrollFrame = null
1299
+ }
695
1300
  }
1301
+ this._topicScrollFrame = requestAnimationFrame(step)
1302
+ }
1303
+
1304
+ cancelProgrammaticScroll() {
1305
+ if (this._topicScrollFrame === null) return
1306
+
1307
+ cancelAnimationFrame(this._topicScrollFrame)
1308
+ this._topicScrollFrame = null
1309
+ }
1310
+
1311
+ handleTopicScrollInput() {
1312
+ this._topicScrollInterruptionVersion += 1
1313
+ this._topicScrollInterrupted = true
1314
+ this.cancelProgrammaticScroll()
696
1315
  }
697
1316
 
698
1317
  handleTopicMoved(event) {
@@ -701,6 +1320,7 @@ export default class extends Controller {
701
1320
  if (String(sourceCreativeId) === String(this.creativeId)) {
702
1321
  // If we were viewing the moved topic, switch to Main
703
1322
  if (String(this.currentTopicId) === String(topicId)) {
1323
+ this._topicScrollInterrupted = false
704
1324
  this.currentTopicId = ""
705
1325
  this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
706
1326
  }
@@ -715,9 +1335,18 @@ export default class extends Controller {
715
1335
  // Don't show badge if we are currently in this topic (shouldn't happen due to list_controller logic, but safety check)
716
1336
  if (String(this.currentTopicId) === String(topicId)) return
717
1337
 
1338
+ const isArchived = this.isArchivedTopic(topicId)
1339
+ if (isArchived) this.archivedWithNewMessages.add(String(topicId))
1340
+
718
1341
  const topicEl = this.listTarget.querySelector(`.topic-tag[data-id="${topicId}"]`)
719
- if (topicEl) {
720
- topicEl.classList.add('has-new-messages')
1342
+ if (topicEl) topicEl.classList.add('has-new-messages')
1343
+
1344
+ this.scheduleUnreadCountRefresh()
1345
+
1346
+ // A collapsed archived section has no chip to badge, so the toggle carries
1347
+ // the notice — otherwise a message in an archived topic is invisible.
1348
+ if (isArchived) {
1349
+ this.listTarget.querySelector('.topic-archived-toggle')?.classList.add('has-new-messages')
721
1350
  }
722
1351
  }
723
1352
 
@@ -725,6 +1354,17 @@ export default class extends Controller {
725
1354
  const topicEl = this.listTarget.querySelector(`.topic-tag[data-id="${topicId}"]`)
726
1355
  if (topicEl) {
727
1356
  topicEl.classList.remove('has-new-messages')
1357
+ topicEl.querySelector('.topic-unread-badge')?.remove()
1358
+ }
1359
+
1360
+ const topic = [...(this.topics || []), ...(this.archivedTopics || [])]
1361
+ .find(candidate => String(candidate.id) === String(topicId))
1362
+ if (topic) topic.unread_count = 0
1363
+
1364
+ // The toggle aggregates every archived topic, so it only clears once none
1365
+ // of them is left unread.
1366
+ if (this.archivedWithNewMessages.delete(String(topicId)) && this.archivedWithNewMessages.size === 0) {
1367
+ this.listTarget.querySelector('.topic-archived-toggle')?.classList.remove('has-new-messages')
728
1368
  }
729
1369
  }
730
1370
 
@@ -745,6 +1385,7 @@ export default class extends Controller {
745
1385
 
746
1386
  if (response.ok) {
747
1387
  const topic = await response.json()
1388
+ this._topicScrollInterrupted = false
748
1389
  this.currentTopicId = topic.id
749
1390
  // Flush save immediately so loadTopics gets the correct value from server
750
1391
  await this.flushSaveLastTopic(topic.id)
@@ -752,7 +1393,7 @@ export default class extends Controller {
752
1393
  // Dispatch change event manually since we skipped the click handler
753
1394
  this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
754
1395
  } else {
755
- alertDialog("Failed to create topic")
1396
+ alertDialog(this._i18n("create_error"))
756
1397
  }
757
1398
  } catch (e) {
758
1399
  console.error("Error creating topic", e)
@@ -776,6 +1417,37 @@ export default class extends Controller {
776
1417
  return this.serverLastTopicId || ""
777
1418
  }
778
1419
 
1420
+ // Is one of the two sources above the preference still set? Not the same
1421
+ // question as "is the getter's answer nonempty" — an override of "" outranks
1422
+ // the preference just as a named one does.
1423
+ get hasDeepLinkSelection() {
1424
+ if (this.overrideTopicId !== undefined && this.overrideTopicId !== null) return true
1425
+
1426
+ return Boolean(new URLSearchParams(window.location.search).get('topic_id'))
1427
+ }
1428
+
1429
+ // A topic leaving the strip — archived or deleted — has to leave both
1430
+ // sources that outrank serverLastTopicId in the currentTopicId getter.
1431
+ // Assigning "" only touches the preference, so on its own the getter goes
1432
+ // on naming the gone topic and every later restoreSelection() finds it
1433
+ // unknown and resets to Main, discarding whatever the user picked instead.
1434
+ releaseDeepLinkSelection(topicId) {
1435
+ this.clearOverrideTopicId()
1436
+ this.clearUrlTopicId(topicId)
1437
+ }
1438
+
1439
+ // Drop ?topic_id= when it names the topic being archived. It is a selection
1440
+ // source in its own right and survives every reload, so leaving it would
1441
+ // re-select the topic the user just archived out of. replaceState, not a
1442
+ // navigation: the chat state around it must stay put.
1443
+ clearUrlTopicId(topicId) {
1444
+ const url = new URL(window.location.href)
1445
+ if (url.searchParams.get('topic_id') !== String(topicId)) return
1446
+
1447
+ url.searchParams.delete('topic_id')
1448
+ window.history.replaceState(window.history.state, '', url.toString())
1449
+ }
1450
+
779
1451
  setOverrideTopicId(id) {
780
1452
  this.overrideTopicId = id ? String(id) : ""
781
1453
  }
@@ -785,60 +1457,907 @@ export default class extends Controller {
785
1457
  }
786
1458
 
787
1459
  set currentTopicId(id) {
788
- this.serverLastTopicId = id ? String(id) : ""
789
- this.debounceSaveLastTopic(id)
1460
+ this.applySelection(id)
1461
+ }
1462
+
1463
+ // pick: this selection is new intent — the user picked it, or the server
1464
+ // told us their preference moved. A restore is not: it re-derives the
1465
+ // selection from state already held, so it is never newer than anything and
1466
+ // must leave the sources that outrank the preference alone. Recording the
1467
+ // preference and saving it happen either way; only the two consequences of
1468
+ // *intent* are gated.
1469
+ applySelection(id, { pick = true, persist = true, pending = false } = {}) {
1470
+ if (persist) this.serverLastTopicId = id ? String(id) : ""
1471
+ if (pick) {
1472
+ // Writing only the preference leaves the two sources that outrank it
1473
+ // in the getter still naming the topic being left, so the getter
1474
+ // keeps answering with it: the next renderTopics() lights the old
1475
+ // chip and the next restoreSelection() navigates back to it. Both
1476
+ // are one-shot pointers at a topic to open, and this selection has
1477
+ // moved off it. Dropped only when they disagree — a deep link that
1478
+ // resolved to the topic now being selected must keep outranking the
1479
+ // stale server last_topic_id for the rest of the popup session.
1480
+ this.releaseSelectionSourcesOtherThan(this.serverLastTopicId)
1481
+ this.selectionEpoch += 1
1482
+ this._pickCreativeId = this._renderedCreativeId
1483
+ this._pickTopicId = this.serverLastTopicId
1484
+ if (pending) {
1485
+ this._pendingPick = {
1486
+ creativeId: this._pickCreativeId,
1487
+ topicId: this._pickTopicId,
1488
+ }
1489
+ }
1490
+ }
1491
+ if (persist) this.debounceSaveLastTopic(id)
1492
+ }
1493
+
1494
+ // Bumped on every selection so an in-flight loadTopics() can tell whether
1495
+ // its answer predates a pick the user has since made.
1496
+ get selectionEpoch() {
1497
+ return this._selectionEpoch || 0
1498
+ }
1499
+
1500
+ set selectionEpoch(value) {
1501
+ this._selectionEpoch = value
1502
+ }
1503
+
1504
+ // Did a pick land after the load at `epoch` started, or is there still an
1505
+ // unsaved pick about the creative that load describes? A later load can
1506
+ // begin before the pick's debounce lands, so request age alone cannot tell
1507
+ // whether its answer predates the pick. The strip the click landed on,
1508
+ // not by this.creativeId — onPopupOpened assigns that synchronously before
1509
+ // the fetch, so a click on the outgoing creative's chips already carries the
1510
+ // incoming id. The rendered strip is what the user was actually looking at.
1511
+ //
1512
+ // An empty pick (All Messages) names no topic to match against the response,
1513
+ // and its provenance is the only thing that distinguishes it from a click on
1514
+ // a stale strip — so it rests on that test alone. A named pick is checked
1515
+ // against the topics too: one whose topic the response does not list was
1516
+ // made against a strip that predates a delete, and keeping it would fail the
1517
+ // lookup in restoreSelection() and persist Main in its place.
1518
+ pickOutranks(epoch, creativeId, topics, archivedTopics) {
1519
+ const hasNewerPick = this.selectionEpoch !== epoch
1520
+ const hasUnsavedPick = this._pendingPick &&
1521
+ String(this._pendingPick.creativeId) === String(creativeId)
1522
+ if (!hasNewerPick && !hasUnsavedPick) return false
1523
+ // creativeId is always truthy here — loadTopics() returns without it —
1524
+ // so a pick made before any strip was rendered fails this too.
1525
+ if (String(this._pickCreativeId) !== String(creativeId)) return false
1526
+ if (!this._pickTopicId) return true
1527
+
1528
+ return [ ...(topics || []), ...(archivedTopics || []) ]
1529
+ .some(t => String(t.id) === String(this._pickTopicId))
1530
+ }
1531
+
1532
+ releaseSelectionSourcesOtherThan(id) {
1533
+ if (this.overrideTopicId !== undefined && this.overrideTopicId !== null &&
1534
+ String(this.overrideTopicId) !== String(id)) {
1535
+ this.clearOverrideTopicId()
1536
+ }
1537
+
1538
+ const urlTopicId = new URLSearchParams(window.location.search).get('topic_id')
1539
+ if (urlTopicId && urlTopicId !== String(id)) {
1540
+ this.clearUrlTopicId(urlTopicId)
1541
+ }
790
1542
  }
791
1543
 
792
1544
  debounceSaveLastTopic(id) {
793
- if (this._saveLastTopicTimer) clearTimeout(this._saveLastTopicTimer)
1545
+ this.cancelPendingSaveLastTopic()
1546
+ // A linked shell can close before this debounce fires. Closing clears
1547
+ // the cached effective origin, and reopening another shell before the
1548
+ // callback runs would otherwise claim that shell's raw id even though
1549
+ // the save belongs to the stream selected here. Capture the requested
1550
+ // creative too: it is the PATCH endpoint, not the effective stream.
1551
+ const effectiveCreativeId = this.effectiveCreativeId
1552
+ const creativeId = this.creativeId
794
1553
  this._saveLastTopicTimer = setTimeout(() => {
795
- this.flushSaveLastTopic(id)
1554
+ this.flushSaveLastTopic(id, effectiveCreativeId, creativeId)
796
1555
  }, 500)
797
1556
  }
798
1557
 
799
- async flushSaveLastTopic(id) {
800
- if (this._saveLastTopicTimer) {
1558
+ // Drop a queued save without sending it. The timer closes over the id it
1559
+ // was scheduled with, so a save that is no longer wanted cannot be talked
1560
+ // out of its value — only cancelled.
1561
+ cancelPendingSaveLastTopic() {
1562
+ if (this._saveLastTopicTimer !== undefined && this._saveLastTopicTimer !== null) {
801
1563
  clearTimeout(this._saveLastTopicTimer)
802
1564
  this._saveLastTopicTimer = null
803
1565
  }
804
- if (this.creativeId) {
805
- await saveLastTopic(this.creativeId, id || null)
1566
+ }
1567
+
1568
+ async flushSaveLastTopic(
1569
+ id,
1570
+ claimedEffectiveCreativeId = this.effectiveCreativeId,
1571
+ requestedCreativeId = this.creativeId
1572
+ ) {
1573
+ this.cancelPendingSaveLastTopic()
1574
+ if (!requestedCreativeId) return
1575
+
1576
+ const creativeId = requestedCreativeId
1577
+ const effectiveCreativeId = claimedEffectiveCreativeId
1578
+ const clientId = this.newLastTopicSaveClientId()
1579
+ const pendingPick = this._pendingPick
1580
+ // The subscription the echo of this save would arrive on. The claim is
1581
+ // taken inside the callback below, which runs whenever the save ahead
1582
+ // of it finishes — by then the stream may already be a different one,
1583
+ // and a claim taken against a stream we have left is owed a message
1584
+ // that cannot arrive.
1585
+ const generation = this.subscriptionGenerationFor(effectiveCreativeId)
1586
+ // One save at a time. Two PATCHes in flight together are persisted in
1587
+ // whatever order the server finishes them, so the preference that
1588
+ // sticks need not be the last one picked. Waiting for the one in
1589
+ // flight is what makes the order they were picked in the order they
1590
+ // are written.
1591
+ this._saveChain = (this._saveChain || Promise.resolve()).then(async () => {
1592
+ // update_last_topic broadcasts to every session of this user, this
1593
+ // one included. Claim the echo here, before the request goes out —
1594
+ // the broadcast is sent server-side before the response is
1595
+ // rendered, so it can beat the save returning.
1596
+ const claimed = generation === this.subscriptionGenerationFor(effectiveCreativeId)
1597
+ if (claimed) {
1598
+ this.pendingSelfEchoes.push(clientId)
1599
+ this.pendingSelfEchoCreativeIds.set(clientId, String(effectiveCreativeId))
1600
+ this.pendingSelfEchoTopicIds.set(clientId, id ? String(id) : "")
1601
+ this.pendingSelfEchoPreviousTopicIds.set(
1602
+ clientId,
1603
+ this.lastKnownRemoteTopicIdFor(effectiveCreativeId) === undefined
1604
+ ? (this.serverLastTopicId ? String(this.serverLastTopicId) : "")
1605
+ : this.lastKnownRemoteTopicIdFor(effectiveCreativeId)
1606
+ )
1607
+ // A save can wait behind another request while the popup closes. It
1608
+ // takes its claim only when the queue reaches it, after the close
1609
+ // handler has already marked the claims that existed then. Its echo
1610
+ // will also be sent into that closed gap, so mark it at creation.
1611
+ if (this._popupClosed && !this.topicsSubscription) {
1612
+ this.possiblyMissedPendingSelfEchoes.add(clientId)
1613
+ }
1614
+ }
1615
+ // A thrown fetch has an unknown outcome: the server may have saved
1616
+ // and broadcast before the connection failed, so keep its claim for
1617
+ // that delayed echo. An HTTP failure is definitive, however, and
1618
+ // update_last_topic returns before broadcasting in that case.
1619
+ const saveResult = await this.saveLastTopicWithTimeout(creativeId, id || null, clientId)
1620
+ const saved = saveResult === true || saveResult?.success === true
1621
+ const savedRevision = this.normalizeLastTopicRevision(saveResult?.lastTopicRevision)
1622
+ const savedRevisionIsCurrent = this.observeLastTopicRevision(
1623
+ effectiveCreativeId,
1624
+ savedRevision
1625
+ )
1626
+ const topicId = id ? String(id) : ""
1627
+ const saveRejected = saveResult === false || saveResult?.success === false
1628
+ const staleLastTopicSave = saveResult?.staleLastTopicSave === true
1629
+ if (claimed && saved) {
1630
+ // The Action Cable echo can arrive before this response. In that
1631
+ // case it has already consumed the claim and removed its metadata;
1632
+ // do not recreate an acknowledgement entry for a completed save.
1633
+ const hasPendingSelfEcho = this.pendingSelfEchoes.includes(clientId)
1634
+ if (hasPendingSelfEcho) {
1635
+ this.saveAcknowledgementVersion += 1
1636
+ this.pendingSelfEchoAcknowledgementVersions.set(
1637
+ clientId,
1638
+ this.saveAcknowledgementVersion
1639
+ )
1640
+ // The response can beat its Action Cable echo. Preserve the
1641
+ // server-issued revision now so a delayed GET can order this
1642
+ // acknowledged claim against a newer ABA snapshot.
1643
+ if (savedRevision) {
1644
+ this.pendingSelfEchoRemoteRevisions.set(clientId, savedRevision)
1645
+ }
1646
+ }
1647
+ const currentCreativeId = String(this.creativeId)
1648
+ const currentStreamIsResolved = this.element.dataset.effectiveCreativeId ||
1649
+ this.knownEffectiveCreativeIds.has(currentCreativeId)
1650
+ const subscribedToAnotherStream = this.topicsSubscription && currentStreamIsResolved &&
1651
+ String(this.effectiveCreativeId) !== String(effectiveCreativeId)
1652
+ const possiblyMissedDuringDisconnect =
1653
+ this.possiblyMissedPendingSelfEchoesDuringDisconnect.has(clientId)
1654
+ if ((this.possiblyMissedPendingSelfEchoes.has(clientId) && !this.topicsSubscription) ||
1655
+ subscribedToAnotherStream ||
1656
+ possiblyMissedDuringDisconnect) {
1657
+ // update_last_topic broadcasts before it returns. With the popup
1658
+ // closed, or after its stream was replaced, that echo was necessarily
1659
+ // sent somewhere this controller can no longer receive it and cannot
1660
+ // settle this claim.
1661
+ // This completed save is also the remote baseline for the next
1662
+ // queued save, which may have claimed after the popup closed.
1663
+ this.setLastKnownRemoteTopicId(effectiveCreativeId, topicId)
1664
+ if (possiblyMissedDuringDisconnect) {
1665
+ this.retirePendingSelfEcho(clientId)
1666
+ } else {
1667
+ this.releasePendingSelfEcho(clientId)
1668
+ }
1669
+ } else {
1670
+ // The replacement subscription may have been active before this
1671
+ // response beat the WebSocket message back to the browser. Keep the
1672
+ // id to consume that echo, but do not use this acknowledged claim to
1673
+ // override a later reopen snapshot.
1674
+ // A completed save establishes the server value that the
1675
+ // next claim was made from. Without moving this baseline,
1676
+ // a later closed save compares its reopen snapshot to a
1677
+ // value from before an earlier local save and mistakes the
1678
+ // legitimate previous value for another session's update.
1679
+ // Do not overwrite a newer Action Cable update after this
1680
+ // save's echo has already been consumed: that echo records
1681
+ // the baseline at broadcast time, and another message may
1682
+ // have advanced it before the HTTP response arrived.
1683
+ if (hasPendingSelfEcho && savedRevisionIsCurrent) {
1684
+ this.setLastKnownRemoteTopicId(effectiveCreativeId, topicId)
1685
+ }
1686
+ this.acknowledgePendingSelfEcho(clientId)
1687
+ }
1688
+ }
1689
+ if (claimed && saveRejected) this.releasePendingSelfEcho(clientId)
1690
+ if (claimed && saveResult === null) {
1691
+ this.scheduleAmbiguousPendingSelfEchoRetirement(clientId)
1692
+ }
1693
+ const rejectedPendingPick = staleLastTopicSave && this._pendingPick === pendingPick &&
1694
+ String(pendingPick?.creativeId) === String(creativeId) &&
1695
+ pendingPick?.topicId === topicId
1696
+ if (rejectedPendingPick) this._pendingPick = null
1697
+ if (saveResult !== null) this.retryDeferredLastTopicReconciliation(effectiveCreativeId)
1698
+ if (saveResult !== false && this._pendingPick === pendingPick &&
1699
+ String(pendingPick?.creativeId) === String(creativeId) &&
1700
+ pendingPick?.topicId === topicId) {
1701
+ this._pendingPick = null
1702
+ }
1703
+ })
1704
+ return this._saveChain
1705
+ }
1706
+
1707
+ // A timed-out fetch can still be running on Rails. Prefix each echo id with
1708
+ // a controller-local session and monotonically increasing sequence so Rails
1709
+ // can reject an older request if a later one from this controller commits
1710
+ // first. The trailing nonce keeps the Action Cable echo identifier unique.
1711
+ newLastTopicSaveClientId() {
1712
+ this._lastTopicSaveSessionId ||= this.lastTopicSaveSessionId()
1713
+ this._lastTopicSaveSequence = this.nextLastTopicSaveSequence()
1714
+ return `${this._lastTopicSaveSessionId}.${this._lastTopicSaveSequence}.${newClientId()}`
1715
+ }
1716
+
1717
+ // A Turbo replacement creates a new controller while an earlier request can
1718
+ // still be running on Rails. Keep the fence identity and counter for this
1719
+ // browser tab so the server can reject that earlier request after the new
1720
+ // controller has saved a later selection.
1721
+ lastTopicSaveSessionId() {
1722
+ try {
1723
+ const sessionId = this.lastTopicSaveWindowSessionId()
1724
+ sessionStorage.setItem(LAST_TOPIC_SAVE_SESSION_STORAGE_KEY, sessionId)
1725
+ return sessionId
1726
+ } catch (_) {
1727
+ return newClientId()
1728
+ }
1729
+ }
1730
+
1731
+ // sessionStorage survives a reload, but browsers copy it into a duplicated
1732
+ // tab. window.name belongs to one top-level browsing context and also
1733
+ // survives reloads, so it keeps Turbo replacements on one fence while a
1734
+ // copied tab starts a separate ordering stream.
1735
+ lastTopicSaveWindowSessionId() {
1736
+ const currentName = window.name || ""
1737
+ if (currentName.startsWith(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX)) {
1738
+ const sessionId = currentName.slice(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX.length)
1739
+ if (/^[A-Za-z0-9-]+$/.test(sessionId)) return sessionId
1740
+ }
1741
+
1742
+ const sessionId = newClientId()
1743
+ window.name = `${LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX}${sessionId}`
1744
+ return sessionId
1745
+ }
1746
+
1747
+ nextLastTopicSaveSequence() {
1748
+ try {
1749
+ const stored = Number(sessionStorage.getItem(LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY))
1750
+ const previous = Number.isSafeInteger(stored) && stored >= 0 ? stored : 0
1751
+ const sequence = Math.max(this._lastTopicSaveSequence || 0, previous) + 1
1752
+ sessionStorage.setItem(LAST_TOPIC_SAVE_SEQUENCE_STORAGE_KEY, String(sequence))
1753
+ return sequence
1754
+ } catch (_) {
1755
+ return (this._lastTopicSaveSequence || 0) + 1
806
1756
  }
807
1757
  }
808
1758
 
809
- migrateLocalStorage() {
1759
+ // A request that never settles must not hold every later pick behind it.
1760
+ // Its server outcome remains ambiguous, so the caller still retains a short
1761
+ // lived echo claim just as it does for a rejected fetch.
1762
+ saveLastTopicWithTimeout(creativeId, topicId, clientId) {
1763
+ const abortController = new AbortController()
1764
+ const request = Promise.resolve(
1765
+ saveLastTopic(creativeId, topicId, clientId, abortController.signal)
1766
+ ).catch(() => null)
1767
+ let timeoutId
1768
+ const timedOutRequest = new Promise(resolve => {
1769
+ timeoutId = setTimeout(() => {
1770
+ // Do not advance the serialized queue while this request can still
1771
+ // reach Rails. An abort makes fetch settle before the next save starts.
1772
+ abortController.abort()
1773
+ request.then(resolve)
1774
+ }, SAVE_REQUEST_TIMEOUT)
1775
+ })
1776
+
1777
+ return Promise.race([request, timedOutRequest]).finally(() => clearTimeout(timeoutId))
1778
+ }
1779
+
1780
+ // The echo names the save it came from, so this is identity, not a guess.
1781
+ // Matching on the topic id instead would let a sibling session that picked
1782
+ // the same topic settle one of our claims, leaving our own echo to arrive
1783
+ // later looking like news and revert whatever the user picked meanwhile.
1784
+ consumeSelfEcho(clientId, lastTopicRevision) {
1785
+ if (!clientId) return false
1786
+
1787
+ const index = this.pendingSelfEchoes.indexOf(clientId)
1788
+ if (index === -1) return false
1789
+
1790
+ // An echo proves the save committed even when it wins the race with its
1791
+ // HTTP response. Keep its ordering data only while an older GET can still
1792
+ // arrive; otherwise that GET could restore the pre-save preference after
1793
+ // the response has cleared _pendingPick.
1794
+ this.saveAcknowledgementVersion += 1
1795
+ this.pendingSelfEchoAcknowledgementVersions.set(
1796
+ clientId,
1797
+ this.saveAcknowledgementVersion
1798
+ )
1799
+ const normalizedRevision = this.normalizeLastTopicRevision(lastTopicRevision)
1800
+ if (normalizedRevision) {
1801
+ this.pendingSelfEchoRemoteRevisions.set(clientId, normalizedRevision)
1802
+ }
1803
+ this.acknowledgedPendingSelfEchoes.add(clientId)
1804
+ this.pendingSelfEchoes.splice(index, 1)
1805
+ // Keep the closed-gap marker with the tombstone. A reopened GET can be
1806
+ // newer than this committed save even when its response races ahead of
1807
+ // the PATCH response; releasing this marker here would make that newer
1808
+ // sibling-session snapshot look stale and replay our old pick over it.
1809
+ this.settledSelfEchoes.add(clientId)
1810
+ this.discardSettledSelfEchoesNoLongerNeeded()
1811
+ return true
1812
+ }
1813
+
1814
+ releasePendingSelfEcho(clientId) {
1815
+ const index = this.pendingSelfEchoes.indexOf(clientId)
1816
+ if (index === -1) return false
1817
+
1818
+ this.pendingSelfEchoes.splice(index, 1)
1819
+ this.discardSelfEchoMetadata(clientId)
1820
+ return true
1821
+ }
1822
+
1823
+ // A Turbo replacement clears controller-local claims while an earlier PATCH
1824
+ // can still commit. The save-session prefix belongs to this top-level tab,
1825
+ // so its orphaned echo remains ours and must not be applied as a sibling
1826
+ // update over the replacement controller's newer pick.
1827
+ isLastTopicSaveFromThisTab(clientId) {
1828
+ const match = String(clientId || "").match(/^([A-Za-z0-9-]+)\.([1-9]\d*)\.[A-Za-z0-9-]+$/)
1829
+ const currentName = window.name || ""
1830
+ if (!currentName.startsWith(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX)) return false
1831
+
1832
+ const sessionId = currentName.slice(LAST_TOPIC_SAVE_WINDOW_NAME_PREFIX.length)
1833
+ return Boolean(match) && /^[A-Za-z0-9-]+$/.test(sessionId) && match[1] === sessionId
1834
+ }
1835
+
1836
+ // A retired ambiguous request can still have committed after its connection
1837
+ // failed. Its sequence is monotonic within a tab session, so one high-water
1838
+ // mark per session keeps all older late echoes non-actionable without retaining
1839
+ // one tombstone per failed save.
1840
+ retirePendingSelfEcho(clientId) {
1841
+ const released = this.releasePendingSelfEcho(clientId)
1842
+ if (released) this.observeRetiredSelfEcho(clientId)
1843
+ return released
1844
+ }
1845
+
1846
+ lastTopicSaveClientIdParts(clientId) {
1847
+ const match = String(clientId || "").match(/^([A-Za-z0-9-]+)\.([1-9]\d*)\.[A-Za-z0-9-]+$/)
1848
+ if (!match) return null
1849
+
1850
+ return { sessionId: match[1], sequence: Number(match[2]) }
1851
+ }
1852
+
1853
+ observeRetiredSelfEcho(clientId) {
1854
+ const parts = this.lastTopicSaveClientIdParts(clientId)
1855
+ if (!parts) return
1856
+
1857
+ const previous = this.retiredSelfEchoSequenceHighWaters.get(parts.sessionId) || 0
1858
+ if (parts.sequence > previous) {
1859
+ this.retiredSelfEchoSequenceHighWaters.set(parts.sessionId, parts.sequence)
1860
+ }
1861
+ }
1862
+
1863
+ isRetiredSelfEcho(clientId) {
1864
+ const parts = this.lastTopicSaveClientIdParts(clientId)
1865
+ if (!parts) return false
1866
+
1867
+ return parts.sequence <= (this.retiredSelfEchoSequenceHighWaters.get(parts.sessionId) || 0)
1868
+ }
1869
+
1870
+ discardSelfEchoMetadata(clientId) {
1871
+ this.cancelAmbiguousPendingSelfEchoRetirement(clientId)
1872
+ this.pendingSelfEchoCreativeIds.delete(clientId)
1873
+ this.pendingSelfEchoTopicIds.delete(clientId)
1874
+ this.pendingSelfEchoPreviousTopicIds.delete(clientId)
1875
+ this.pendingSelfEchoAcknowledgementVersions.delete(clientId)
1876
+ this.pendingSelfEchoRemoteRevisions.delete(clientId)
1877
+ this.possiblyMissedPendingSelfEchoes.delete(clientId)
1878
+ this.possiblyMissedPendingSelfEchoesDuringDisconnect.delete(clientId)
1879
+ this.acknowledgedPendingSelfEchoes.delete(clientId)
1880
+ this.settledSelfEchoes.delete(clientId)
1881
+ }
1882
+
1883
+ // A rejected fetch has an ambiguous server outcome, so retain its claim long
1884
+ // enough for a delayed self-echo. It cannot remain forever, though: a request
1885
+ // that never reached Rails has no echo or revision to resolve it, and would
1886
+ // otherwise defer every later revisioned snapshot on this stream.
1887
+ scheduleAmbiguousPendingSelfEchoRetirement(clientId) {
1888
+ if (!this.pendingSelfEchoes.includes(clientId)) return
1889
+
1890
+ this.cancelAmbiguousPendingSelfEchoRetirement(clientId)
1891
+ const timeout = setTimeout(() => {
1892
+ this.ambiguousPendingSelfEchoRetirements.delete(clientId)
1893
+ const creativeId = this.pendingSelfEchoCreativeIds.get(clientId)
1894
+ if (creativeId === undefined ||
1895
+ this.acknowledgedPendingSelfEchoes.has(clientId) ||
1896
+ this.pendingSelfEchoRemoteRevisions.has(clientId)) return
1897
+
1898
+ this.retirePendingSelfEcho(clientId)
1899
+ this.retryDeferredLastTopicReconciliation(creativeId)
1900
+ }, AMBIGUOUS_SAVE_CLAIM_TIMEOUT)
1901
+ this.ambiguousPendingSelfEchoRetirements.set(clientId, timeout)
1902
+ }
1903
+
1904
+ cancelAmbiguousPendingSelfEchoRetirement(clientId) {
1905
+ const timeout = this.ambiguousPendingSelfEchoRetirements.get(clientId)
1906
+ if (timeout !== undefined) clearTimeout(timeout)
1907
+ this.ambiguousPendingSelfEchoRetirements.delete(clientId)
1908
+ }
1909
+
1910
+ get ambiguousPendingSelfEchoRetirements() {
1911
+ return this._ambiguousPendingSelfEchoRetirements ||
1912
+ (this._ambiguousPendingSelfEchoRetirements = new Map())
1913
+ }
1914
+
1915
+ get retiredSelfEchoSequenceHighWaters() {
1916
+ return this._retiredSelfEchoSequenceHighWaters ||
1917
+ (this._retiredSelfEchoSequenceHighWaters = new Map())
1918
+ }
1919
+
1920
+ // An early echo can be the only proof that an in-flight GET predates a
1921
+ // committed save. Once every GET that started before it has finished, the
1922
+ // tombstone has no ordering work left and must not accumulate in a long-lived
1923
+ // popup controller. Compare the save version captured by each active load,
1924
+ // not its unrelated request sequence number.
1925
+ discardSettledSelfEchoesNoLongerNeeded() {
1926
+ for (const clientId of [...this.settledSelfEchoes]) {
1927
+ const acknowledgementVersion = this.pendingSelfEchoAcknowledgementVersions.get(clientId)
1928
+ const hasOlderLoad = [...this.activeLoadAcknowledgementVersions.values()].some(
1929
+ loadAcknowledgementVersion => loadAcknowledgementVersion < acknowledgementVersion
1930
+ )
1931
+ if (!hasOlderLoad) this.discardSelfEchoMetadata(clientId)
1932
+ }
1933
+ }
1934
+
1935
+ // Ids of saves this client has made and not yet seen come back. The echo of
1936
+ // our own save is not news: it names what we already asked for, and by the
1937
+ // time it arrives the user may have picked something else — applying it
1938
+ // then reverts the newer pick and persists the older topic over it.
1939
+ get pendingSelfEchoes() {
1940
+ return this._pendingSelfEchoes || (this._pendingSelfEchoes = [])
1941
+ }
1942
+
1943
+ get pendingSelfEchoCreativeIds() {
1944
+ return this._pendingSelfEchoCreativeIds || (this._pendingSelfEchoCreativeIds = new Map())
1945
+ }
1946
+
1947
+ get pendingSelfEchoTopicIds() {
1948
+ return this._pendingSelfEchoTopicIds || (this._pendingSelfEchoTopicIds = new Map())
1949
+ }
1950
+
1951
+ // The last preference observed from the server is the baseline for a save.
1952
+ // It belongs to an effective creative stream: a controller can move between
1953
+ // creatives before the next fetch resolves, and one stream's preference is
1954
+ // never evidence about another stream.
1955
+ get lastKnownRemoteTopicId() {
1956
+ return this.lastKnownRemoteTopicIdFor(this.effectiveCreativeId)
1957
+ }
1958
+
1959
+ set lastKnownRemoteTopicId(value) {
1960
+ this.setLastKnownRemoteTopicId(this.effectiveCreativeId, value)
1961
+ }
1962
+
1963
+ lastKnownRemoteTopicIdFor(creativeId) {
1964
+ return this.lastKnownRemoteTopicIds.get(String(creativeId))
1965
+ }
1966
+
1967
+ setLastKnownRemoteTopicId(creativeId, value) {
1968
+ this.lastKnownRemoteTopicIds.set(String(creativeId), value ? String(value) : "")
1969
+ }
1970
+
1971
+ get lastKnownRemoteTopicIds() {
1972
+ return this._lastKnownRemoteTopicIds || (this._lastKnownRemoteTopicIds = new Map())
1973
+ }
1974
+
1975
+ // Action Cable is ordered only within a connection. A delayed older message
1976
+ // can therefore arrive after a newer GET, response, or broadcast. Keep the
1977
+ // server-issued revision per effective stream and never apply it backward.
1978
+ observeLastTopicRevision(creativeId, revision) {
1979
+ if (!revision) return true
1980
+
1981
+ const streamCreativeId = String(creativeId)
1982
+ const previous = this.highestLastTopicRevisions.get(streamCreativeId)
1983
+ const comparison = this.compareLastTopicRevisions(revision, previous)
1984
+ if (comparison !== null && comparison < 0) return false
1985
+ if (comparison === null || comparison > 0) {
1986
+ this.highestLastTopicRevisions.set(streamCreativeId, revision)
1987
+ }
1988
+ return true
1989
+ }
1990
+
1991
+ get highestLastTopicRevisions() {
1992
+ return this._highestLastTopicRevisions || (this._highestLastTopicRevisions = new Map())
1993
+ }
1994
+
1995
+ // The server resolves TopicsChannel subscriptions for linked shells to their
1996
+ // origin stream. Keep that result separately from the popup-scoped dataset:
1997
+ // the latter is deliberately cleared while a replacement load is pending.
1998
+ get knownEffectiveCreativeIds() {
1999
+ return this._knownEffectiveCreativeIds || (this._knownEffectiveCreativeIds = new Map())
2000
+ }
2001
+
2002
+ get pendingSelfEchoPreviousTopicIds() {
2003
+ return this._pendingSelfEchoPreviousTopicIds || (this._pendingSelfEchoPreviousTopicIds = new Map())
2004
+ }
2005
+
2006
+ get pendingSelfEchoAcknowledgementVersions() {
2007
+ return this._pendingSelfEchoAcknowledgementVersions || (this._pendingSelfEchoAcknowledgementVersions = new Map())
2008
+ }
2009
+
2010
+ // The preference row id distinguishes a record recreated after clearing the
2011
+ // selection; the per-row revision orders saves made while it exists. Together
2012
+ // they are a server-issued ordering token, rather than an inference from a
2013
+ // topic id that can repeat in an ABA change (Main -> Alpha -> Main).
2014
+ normalizeLastTopicRevision(value) {
2015
+ if (!Array.isArray(value) || value.length !== 2) return null
2016
+ const revision = value.map(Number)
2017
+ return revision.every(Number.isSafeInteger) ? revision : null
2018
+ }
2019
+
2020
+ compareLastTopicRevisions(left, right) {
2021
+ if (!left || !right) return null
2022
+ return left[0] === right[0] ? left[1] - right[1] : left[0] - right[0]
2023
+ }
2024
+
2025
+ get pendingSelfEchoRemoteRevisions() {
2026
+ return this._pendingSelfEchoRemoteRevisions || (this._pendingSelfEchoRemoteRevisions = new Map())
2027
+ }
2028
+
2029
+ // Echoes normally remove their claims. A short-lived tombstone retains the
2030
+ // save order for GETs already in progress when the echo arrived.
2031
+ get settledSelfEchoes() {
2032
+ return this._settledSelfEchoes || (this._settledSelfEchoes = new Set())
2033
+ }
2034
+
2035
+ get activeLoadAcknowledgementVersions() {
2036
+ return this._activeLoadAcknowledgementVersions || (this._activeLoadAcknowledgementVersions = new Map())
2037
+ }
2038
+
2039
+ get saveAcknowledgementVersion() {
2040
+ return this._saveAcknowledgementVersion || 0
2041
+ }
2042
+
2043
+ set saveAcknowledgementVersion(value) {
2044
+ this._saveAcknowledgementVersion = value
2045
+ }
2046
+
2047
+ // These claims were already in flight when the popup unsubscribed. If their
2048
+ // response arrives while the popup remains closed, the broadcast necessarily
2049
+ // went into that gap. A replacement subscription can receive an echo when it
2050
+ // reopens before the response arrives, though.
2051
+ get possiblyMissedPendingSelfEchoes() {
2052
+ return this._possiblyMissedPendingSelfEchoes || (this._possiblyMissedPendingSelfEchoes = new Set())
2053
+ }
2054
+
2055
+ // A broadcast sent after Action Cable disconnects is not replayed after the
2056
+ // connection returns. Keep unresolved saves until their HTTP outcome tells us
2057
+ // whether one could have been sent in that gap; acknowledged saves can be
2058
+ // retired immediately because their broadcast has already been sent.
2059
+ get possiblyMissedPendingSelfEchoesDuringDisconnect() {
2060
+ return this._possiblyMissedPendingSelfEchoesDuringDisconnect ||
2061
+ (this._possiblyMissedPendingSelfEchoesDuringDisconnect = new Set())
2062
+ }
2063
+
2064
+ handleTopicsSubscriptionDisconnected() {
2065
+ const deferredCreativeIds = new Set()
2066
+ for (const clientId of [...this.pendingSelfEchoes]) {
2067
+ const creativeId = this.pendingSelfEchoCreativeIds.get(clientId)
2068
+ if (creativeId === undefined) continue
2069
+
2070
+ if (!this.acknowledgedPendingSelfEchoes.has(clientId)) {
2071
+ this.possiblyMissedPendingSelfEchoesDuringDisconnect.add(clientId)
2072
+ continue
2073
+ }
2074
+
2075
+ this.setLastKnownRemoteTopicId(creativeId, this.pendingSelfEchoTopicIds.get(clientId))
2076
+ this.retirePendingSelfEcho(clientId)
2077
+ deferredCreativeIds.add(creativeId)
2078
+ }
2079
+ for (const creativeId of deferredCreativeIds) {
2080
+ this.retryDeferredLastTopicReconciliation(creativeId)
2081
+ }
2082
+ }
2083
+
2084
+ markPendingSelfEchoesAsPossiblyMissed() {
2085
+ for (const clientId of this.pendingSelfEchoes) {
2086
+ if (!this.acknowledgedPendingSelfEchoes.has(clientId)) {
2087
+ this.possiblyMissedPendingSelfEchoes.add(clientId)
2088
+ }
2089
+ }
2090
+ }
2091
+
2092
+ // A successful response means the broadcast was already sent. Once this
2093
+ // popup unsubscribes, that echo cannot be replayed to settle its claim, so
2094
+ // retaining the acknowledgement metadata would leak it indefinitely.
2095
+ releaseAcknowledgedPendingSelfEchoes() {
2096
+ for (const clientId of [...this.pendingSelfEchoes]) {
2097
+ if (this.acknowledgedPendingSelfEchoes.has(clientId)) {
2098
+ this.releasePendingSelfEcho(clientId)
2099
+ }
2100
+ }
2101
+ }
2102
+
2103
+ // A new requested creative initially has no client-side effective stream
2104
+ // entry. Its subscription may still resolve server-side to the stream of a
2105
+ // pending save, so do not release that claim until this GET supplies the
2106
+ // effective id. Once it does, an acknowledged claim on another stream can
2107
+ // no longer receive an echo on this subscription and must not be retained.
2108
+ releaseAcknowledgedPendingSelfEchoesOutside(effectiveCreativeId) {
2109
+ const streamCreativeId = String(effectiveCreativeId)
2110
+ for (const clientId of [...this.pendingSelfEchoes]) {
2111
+ if (this.pendingSelfEchoCreativeIds.get(clientId) !== streamCreativeId &&
2112
+ this.acknowledgedPendingSelfEchoes.has(clientId)) {
2113
+ this.releasePendingSelfEcho(clientId)
2114
+ }
2115
+ }
2116
+ }
2117
+
2118
+ // A successful response proves the request committed. A close before the
2119
+ // response means the matching broadcast was missed and can be retired; an
2120
+ // open popup keeps its id long enough to consume an echo still in flight,
2121
+ // without allowing it to override a later reopen snapshot.
2122
+ get acknowledgedPendingSelfEchoes() {
2123
+ return this._acknowledgedPendingSelfEchoes || (this._acknowledgedPendingSelfEchoes = new Set())
2124
+ }
2125
+
2126
+ acknowledgePendingSelfEcho(clientId) {
2127
+ if (this.pendingSelfEchoes.includes(clientId)) {
2128
+ this.possiblyMissedPendingSelfEchoes.delete(clientId)
2129
+ this.acknowledgedPendingSelfEchoes.add(clientId)
2130
+ }
2131
+ }
2132
+
2133
+ latestPendingSelfEchoTopicIdFor(creativeId, snapshotTopicId, acknowledgedSaveVersion, snapshotTopicRevision) {
2134
+ const streamCreativeId = String(creativeId)
2135
+ let topicId = snapshotTopicId
2136
+ let found = false
2137
+ const orderingClaimIds = [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ]
2138
+ const remainingClaimIds = new Set(orderingClaimIds)
2139
+
2140
+ // Saves are serialized, so each claim records the topic written by the
2141
+ // previous claim. A stale response can predate more than one completed
2142
+ // save; follow that chain instead of restoring only its first successor.
2143
+ // A claim acknowledged before this load began describes an older snapshot
2144
+ // and cannot advance it.
2145
+ for (;;) {
2146
+ const clientId = orderingClaimIds.find(id => {
2147
+ if (!remainingClaimIds.has(id)) return false
2148
+ const acknowledgedAfterLoadStarted =
2149
+ this.acknowledgedPendingSelfEchoes.has(id) &&
2150
+ this.pendingSelfEchoAcknowledgementVersions.get(id) > acknowledgedSaveVersion
2151
+ const revisionComparison = this.compareLastTopicRevisions(
2152
+ this.pendingSelfEchoRemoteRevisions.get(id),
2153
+ snapshotTopicRevision
2154
+ )
2155
+ return this.pendingSelfEchoCreativeIds.get(id) === streamCreativeId &&
2156
+ (!this.acknowledgedPendingSelfEchoes.has(id) || acknowledgedAfterLoadStarted) &&
2157
+ (revisionComparison === null
2158
+ ? this.pendingSelfEchoPreviousTopicIds.get(id) === topicId
2159
+ : revisionComparison > 0)
2160
+ })
2161
+ if (!clientId) return found ? topicId : undefined
2162
+
2163
+ found = true
2164
+ remainingClaimIds.delete(clientId)
2165
+ topicId = this.pendingSelfEchoTopicIds.get(clientId)
2166
+ }
2167
+ }
2168
+
2169
+ // A pick made before this replacement load normally outranks its answer: the
2170
+ // answer may simply predate the save. A claim that was in flight across a
2171
+ // close is the exception. When the snapshot has advanced from that save's
2172
+ // known baseline, the closed stream missed a newer cross-session write, so
2173
+ // replaying the local pick would immediately write the old topic back.
2174
+ pendingPickWasSupersededWhileClosed(creativeId, snapshotTopicId, snapshotTopicRevision) {
2175
+ if (!this._pendingPick) return false
2176
+
2177
+ const streamCreativeId = String(creativeId)
2178
+ return [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ].some(clientId => {
2179
+ if (!this.possiblyMissedPendingSelfEchoes.has(clientId) ||
2180
+ this.pendingSelfEchoCreativeIds.get(clientId) !== streamCreativeId ||
2181
+ this.pendingSelfEchoTopicIds.get(clientId) !== this._pendingPick.topicId) return false
2182
+
2183
+ const revisionComparison = this.compareLastTopicRevisions(
2184
+ snapshotTopicRevision,
2185
+ this.pendingSelfEchoRemoteRevisions.get(clientId)
2186
+ )
2187
+ return revisionComparison === null
2188
+ ? this.pendingSelfEchoPreviousTopicIds.get(clientId) !== snapshotTopicId
2189
+ : revisionComparison > 0
2190
+ })
2191
+ }
2192
+
2193
+ // A revisioned snapshot can only be compared safely once every local save
2194
+ // involved in that comparison has supplied its revision. Until then an ABA
2195
+ // snapshot can look like the predecessor of an in-flight save by topic id.
2196
+ hasUnacknowledgedRevisionedSaveFor(creativeId, snapshotTopicRevision) {
2197
+ if (!snapshotTopicRevision) return false
2198
+
2199
+ const streamCreativeId = String(creativeId)
2200
+ return this.pendingSelfEchoes.some(clientId =>
2201
+ this.pendingSelfEchoCreativeIds.get(clientId) === streamCreativeId &&
2202
+ !this.acknowledgedPendingSelfEchoes.has(clientId) &&
2203
+ !this.pendingSelfEchoRemoteRevisions.has(clientId)
2204
+ )
2205
+ }
2206
+
2207
+ get deferredLastTopicReconciliations() {
2208
+ return this._deferredLastTopicReconciliations || (this._deferredLastTopicReconciliations = new Set())
2209
+ }
2210
+
2211
+ retryDeferredLastTopicReconciliation(creativeId) {
2212
+ const streamCreativeId = String(creativeId)
2213
+ if (!this.deferredLastTopicReconciliations.has(streamCreativeId) ||
2214
+ String(this.effectiveCreativeId) !== streamCreativeId) return
2215
+
2216
+ this.deferredLastTopicReconciliations.delete(streamCreativeId)
2217
+ this.loadTopics()
2218
+ }
2219
+
2220
+ // Bumped for an individual stream when that stream is permanently gone. A
2221
+ // popup can temporarily subscribe to another creative, so invalidating that
2222
+ // subscription must not prevent a queued save for the original stream from
2223
+ // claiming its own echo when the user returns to it.
2224
+ subscriptionGenerationFor(creativeId) {
2225
+ const streamCreativeId = String(creativeId)
2226
+ if (!this.subscriptionGenerations.has(streamCreativeId)) {
2227
+ this.subscriptionGenerations.set(streamCreativeId, 0)
2228
+ }
2229
+ return this.subscriptionGenerations.get(streamCreativeId)
2230
+ }
2231
+
2232
+ bumpSubscriptionGenerationFor(creativeId) {
2233
+ const streamCreativeId = String(creativeId)
2234
+ this.subscriptionGenerations.set(
2235
+ streamCreativeId,
2236
+ this.subscriptionGenerationFor(streamCreativeId) + 1
2237
+ )
2238
+ }
2239
+
2240
+ get subscriptionGenerations() {
2241
+ return this._subscriptionGenerations || (this._subscriptionGenerations = new Map())
2242
+ }
2243
+
2244
+ // The legacy key is adopted only as a stand-in for a preference the server
2245
+ // does not hold yet. A winning empty pick is a preference — it just names no
2246
+ // topic, so it is indistinguishable here from "server holds nothing", and
2247
+ // adopting the legacy value would hand the user back a topic they did not
2248
+ // ask for and persist it. The caller knows which of the two it is.
2249
+ //
2250
+ // The key still goes, either way: the pick supersedes it, and its own save
2251
+ // is already on the way, so leaving it behind would only re-apply a value
2252
+ // the user has moved off on the next load.
2253
+ migrateLocalStorage({ keepEmptyPick = false } = {}) {
810
2254
  const key = `collavre_creative_${this.creativeId}_last_topic`
811
2255
  const localValue = localStorage.getItem(key)
812
- if (localValue && !this.serverLastTopicId) {
2256
+ if (localValue && !this.serverLastTopicId && !keepEmptyPick) {
813
2257
  this.serverLastTopicId = localValue
814
- saveLastTopic(this.creativeId, localValue)
2258
+ // The migration is a save like any other. Giving it a client id
2259
+ // prevents its broadcast from being mistaken for another session's
2260
+ // update if the user chooses a topic before that echo arrives.
2261
+ this.flushSaveLastTopic(localValue)
815
2262
  }
816
2263
  localStorage.removeItem(key)
817
2264
  }
818
2265
 
819
- subscribe() {
2266
+ subscribe({ preservePendingSelfEchoes = false } = {}) {
820
2267
  const creativeId = this.creativeId
821
2268
  if (!creativeId) return
822
2269
 
823
2270
  if (this.topicsSubscription && this.subscribedCreativeId === String(creativeId)) return
824
2271
 
825
- this.unsubscribe()
2272
+ if (this.topicsSubscription) this.unsubscribe({ preservePendingSelfEchoes })
826
2273
 
827
2274
  this.subscribedCreativeId = String(creativeId)
828
2275
  this.topicsSubscription = createSubscription(
829
2276
  { channel: 'TopicsChannel', creative_id: this.creativeId },
830
2277
  {
831
- received: (data) => this.handleTopicMessage(data)
2278
+ received: (data) => this.handleTopicMessage(data),
2279
+ disconnected: () => this.handleTopicsSubscriptionDisconnected(),
2280
+ // A refused subscription is different: it is not retried, so
2281
+ // nothing outstanding or queued on this stream can ever settle.
2282
+ // Claims for another creative may still settle if the popup returns.
2283
+ rejected: () => this.dropPendingSelfEchoesForCreative(creativeId),
832
2284
  }
833
2285
  )
834
2286
  }
835
2287
 
836
- unsubscribe() {
2288
+ dropPendingSelfEchoes() {
2289
+ const streamCreativeIds = new Set([
2290
+ ...this.pendingSelfEchoCreativeIds.values(),
2291
+ ...this.subscriptionGenerations.keys(),
2292
+ ])
2293
+ this.pendingSelfEchoes.length = 0
2294
+ this.pendingSelfEchoCreativeIds.clear()
2295
+ this.pendingSelfEchoTopicIds.clear()
2296
+ this.pendingSelfEchoPreviousTopicIds.clear()
2297
+ this.pendingSelfEchoAcknowledgementVersions.clear()
2298
+ this.pendingSelfEchoRemoteRevisions.clear()
2299
+ this.possiblyMissedPendingSelfEchoes.clear()
2300
+ this.possiblyMissedPendingSelfEchoesDuringDisconnect.clear()
2301
+ this.acknowledgedPendingSelfEchoes.clear()
2302
+ this.settledSelfEchoes.clear()
2303
+ this.retiredSelfEchoSequenceHighWaters.clear()
2304
+ // Emptying the array cannot reach a claim that has not been taken yet.
2305
+ // A save waiting its turn on the chain takes one when it runs, and by
2306
+ // then every stream has gone; its stream generation tells it so.
2307
+ for (const creativeId of streamCreativeIds) {
2308
+ this.bumpSubscriptionGenerationFor(creativeId)
2309
+ }
2310
+ }
2311
+
2312
+ dropPendingSelfEchoesForCreative(creativeId) {
2313
+ const streamCreativeId = String(creativeId)
2314
+ const clientIds = new Set([
2315
+ ...this.pendingSelfEchoes,
2316
+ ...this.settledSelfEchoes,
2317
+ ].filter(clientId => this.pendingSelfEchoCreativeIds.get(clientId) === streamCreativeId))
2318
+
2319
+ this._pendingSelfEchoes = this.pendingSelfEchoes.filter(clientId => !clientIds.has(clientId))
2320
+ for (const clientId of clientIds) this.discardSelfEchoMetadata(clientId)
2321
+ this.bumpSubscriptionGenerationFor(streamCreativeId)
2322
+ }
2323
+
2324
+ // A save can begin before a linked shell's topics response reveals that it
2325
+ // shares its origin's preference stream. Re-key that claim before pruning
2326
+ // so its identified echo remains recognisable on the resolved stream.
2327
+ remapPendingSelfEchoesForCreative(requestedCreativeId, effectiveCreativeId) {
2328
+ const requestedId = String(requestedCreativeId)
2329
+ const resolvedId = String(effectiveCreativeId)
2330
+ if (requestedId === resolvedId) return
2331
+ const requestedBaseline = this.lastKnownRemoteTopicIds.get(requestedId)
2332
+ if (requestedBaseline !== undefined) {
2333
+ if (!this.lastKnownRemoteTopicIds.has(resolvedId)) {
2334
+ this.lastKnownRemoteTopicIds.set(resolvedId, requestedBaseline)
2335
+ }
2336
+ this.lastKnownRemoteTopicIds.delete(requestedId)
2337
+ }
2338
+ const requestedRevision = this.highestLastTopicRevisions.get(requestedId)
2339
+ if (requestedRevision) {
2340
+ this.observeLastTopicRevision(resolvedId, requestedRevision)
2341
+ this.highestLastTopicRevisions.delete(requestedId)
2342
+ }
2343
+
2344
+ for (const clientId of [ ...this.pendingSelfEchoes, ...this.settledSelfEchoes ]) {
2345
+ if (this.pendingSelfEchoCreativeIds.get(clientId) === requestedId) {
2346
+ this.pendingSelfEchoCreativeIds.set(clientId, resolvedId)
2347
+ }
2348
+ }
2349
+ }
2350
+
2351
+ unsubscribe({ preservePendingSelfEchoes = false } = {}) {
837
2352
  if (this.topicsSubscription) {
838
2353
  this.topicsSubscription.unsubscribe()
839
2354
  this.topicsSubscription = null
840
2355
  }
841
2356
  this.subscribedCreativeId = null
2357
+ // The stream is per creative, so echoes still owed to us on the one we
2358
+ // are leaving are not coming and nothing can settle their claims.
2359
+ // Unlike a dropped connection, there is no reconnect to wait for.
2360
+ if (!preservePendingSelfEchoes) this.dropPendingSelfEchoes()
842
2361
  }
843
2362
 
844
2363
  handleTopicMessage(data) {
@@ -849,9 +2368,68 @@ export default class extends Controller {
849
2368
  if (action === "last_topic_changed") {
850
2369
  // Broadcast is already scoped to the current user via user-specific channel
851
2370
  const newTopicId = data.last_topic_id ? String(data.last_topic_id) : ""
2371
+ const lastTopicRevision = this.normalizeLastTopicRevision(data.last_topic_revision)
2372
+ // A retired ambiguous save remains non-actionable, but it can still have
2373
+ // committed after the request failed. Its revision must advance this
2374
+ // stream's high-water mark so an older in-flight GET cannot restore the
2375
+ // preference from before that save.
2376
+ if (!this.pendingSelfEchoes.includes(data.client_id) && this.isRetiredSelfEcho(data.client_id)) {
2377
+ this.observeLastTopicRevision(this.effectiveCreativeId, lastTopicRevision)
2378
+ return
2379
+ }
2380
+ // A linked shell can receive an echo before its load resolves the origin.
2381
+ // The claim already knows that effective stream, while effectiveCreativeId
2382
+ // still names the shell; advance the baseline on the claimed stream.
2383
+ const claimedEffectiveCreativeId = this.pendingSelfEchoCreativeIds.get(data.client_id) || this.effectiveCreativeId
2384
+ const isCurrentRevision = this.observeLastTopicRevision(
2385
+ claimedEffectiveCreativeId,
2386
+ lastTopicRevision
2387
+ )
2388
+ const consumedSelfEcho = this.consumeSelfEcho(data.client_id, lastTopicRevision)
2389
+ const orphanedSelfEcho = !consumedSelfEcho && this.isLastTopicSaveFromThisTab(data.client_id)
2390
+ const hasNewerPendingPick = this._pendingPick &&
2391
+ String(this.effectiveCreativeIdFor(this._pendingPick.creativeId)) === String(claimedEffectiveCreativeId)
2392
+ if (consumedSelfEcho || (orphanedSelfEcho && hasNewerPendingPick)) {
2393
+ if (isCurrentRevision) {
2394
+ this.setLastKnownRemoteTopicId(claimedEffectiveCreativeId, newTopicId)
2395
+ }
2396
+ this.retryDeferredLastTopicReconciliation(claimedEffectiveCreativeId)
2397
+ return
2398
+ }
2399
+ if (!isCurrentRevision) return
2400
+ this.setLastKnownRemoteTopicId(this.effectiveCreativeId, newTopicId)
2401
+ // A deep-link restore may have queued a write even when this broadcast
2402
+ // repeats the preference already in serverLastTopicId. The sibling
2403
+ // session still established that preference, so the queued one-shot link
2404
+ // must not write itself back over it.
2405
+ if (this.hasDeepLinkSelection) this.cancelPendingSaveLastTopic()
852
2406
  if (newTopicId !== this.serverLastTopicId) {
853
2407
  this.serverLastTopicId = newTopicId
854
- this.selectTopic(newTopicId)
2408
+ // Another session moved the preference; nobody clicked in this
2409
+ // popup. A deep link outranks the preference in the getter, so
2410
+ // following the broadcast would light a chip the getter does not
2411
+ // name — and selectTopic() writes through the setter, which
2412
+ // releases that link on the way. It is a one-shot pointer with
2413
+ // nowhere to be recovered from: ?topic_id= is dropped from the
2414
+ // URL, so not even a reload gets the linked conversation back.
2415
+ // Record the preference, leave the view where the link put it.
2416
+ //
2417
+ // Every selection queues a save, restores included, so landing
2418
+ // on the link 500ms ago left one holding the linked topic. It
2419
+ // describes a selection this popup has just conceded is not the
2420
+ // preference; letting it land would write the link back over
2421
+ // what the other session set. Following the broadcast re-arms
2422
+ // the debounce with the broadcast's own value, so only the path
2423
+ // that does not follow has anything to cancel.
2424
+ if (!this.hasDeepLinkSelection) {
2425
+ // The save belonged to the controller Turbo replaced, not to a
2426
+ // new pick in this one. Reflect its committed preference without
2427
+ // creating another pending pick or writing it back to the server.
2428
+ this.selectTopic(
2429
+ newTopicId,
2430
+ orphanedSelfEcho ? { pick: false, persist: false } : undefined
2431
+ )
2432
+ }
855
2433
  }
856
2434
  return
857
2435
  }
@@ -867,6 +2445,12 @@ export default class extends Controller {
867
2445
  }
868
2446
 
869
2447
  if (action === "archived" || action === "unarchived") {
2448
+ // loadTopics() only refreshes archivedTopics when its fetch resolves,
2449
+ // and list_controller routes every incoming stream through
2450
+ // isArchivedTopic in the meantime. The broadcast already carries the
2451
+ // membership change, so apply it now rather than letting that window
2452
+ // route on the old answer.
2453
+ this.applyArchiveTransition(action, data.topic || { id: data.topic_id })
870
2454
  this.loadTopics()
871
2455
  return
872
2456
  }
@@ -887,7 +2471,16 @@ export default class extends Controller {
887
2471
  if (existsByName) return
888
2472
 
889
2473
  this.topics = [...topics, data.topic]
890
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
2474
+ // This arrived on the subscription for the creative currently open.
2475
+ // Unlike a local re-render of cached topics, it is not about the
2476
+ // outgoing creative whose strip may still be on screen during a switch.
2477
+ this.renderTopics(
2478
+ this.topics,
2479
+ this.canManageTopics,
2480
+ this.canCreateTopic,
2481
+ this.canSetPrimaryAgent,
2482
+ this.creativeId
2483
+ )
891
2484
 
892
2485
  // Auto-select the new topic if created by the current user
893
2486
  const currentUserId = document.body.dataset.currentUserId
@@ -915,7 +2508,7 @@ export default class extends Controller {
915
2508
  })
916
2509
 
917
2510
  this.topics = reorderedTopics
918
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
2511
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
919
2512
  this.restoreSelection()
920
2513
  }
921
2514
 
@@ -925,7 +2518,7 @@ export default class extends Controller {
925
2518
  if (index === -1) return
926
2519
 
927
2520
  this.topics[index] = { ...this.topics[index], ...updatedTopic }
928
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
2521
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
929
2522
  this.restoreSelection()
930
2523
  }
931
2524
 
@@ -933,16 +2526,26 @@ export default class extends Controller {
933
2526
  if (!topicId) return
934
2527
 
935
2528
  const topics = this.topics || []
2529
+ const archivedTopics = this.archivedTopics || []
936
2530
  const nextTopics = topics.filter((topic) => String(topic.id) !== String(topicId))
937
- if (nextTopics.length === topics.length) return
2531
+ // An archived topic is deletable and now selectable, so the "deleted"
2532
+ // broadcast has to reach this cache too — nothing else does. It is the
2533
+ // only removal path that runs without a loadTopics(), so a topic dropped
2534
+ // from here would keep an openable chip and, through pruneArchivedBadges
2535
+ // never running, a lit toggle for a conversation that no longer exists.
2536
+ const nextArchivedTopics = archivedTopics.filter((topic) => String(topic.id) !== String(topicId))
2537
+ if (nextTopics.length === topics.length && nextArchivedTopics.length === archivedTopics.length) return
938
2538
 
939
2539
  this.topics = nextTopics
2540
+ this.archivedTopics = nextArchivedTopics
2541
+ this.pruneArchivedBadges()
940
2542
  if (String(this.currentTopicId) === String(topicId)) {
2543
+ this.releaseDeepLinkSelection(topicId)
941
2544
  this.currentTopicId = ""
942
2545
  this.dispatch("change", { detail: { topicId: "", mainTopicId: this.mainTopicId } })
943
2546
  }
944
2547
 
945
- this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
2548
+ this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic, this.canSetPrimaryAgent)
946
2549
  this.restoreSelection()
947
2550
  }
948
2551
 
@@ -985,6 +2588,7 @@ export default class extends Controller {
985
2588
 
986
2589
  const result = await createTopicWithComments(this.creativeId, name, commentIds)
987
2590
  if (result.ok) {
2591
+ this._topicScrollInterrupted = false
988
2592
  this.currentTopicId = result.topic.id
989
2593
  await this.flushSaveLastTopic(result.topic.id)
990
2594
  await this.loadTopics()
@@ -1000,6 +2604,45 @@ export default class extends Controller {
1000
2604
  }
1001
2605
  }
1002
2606
 
2607
+ // Localized strings are handed down from the ERB partial as data
2608
+ // attributes; the English literals are last-resort fallbacks for when the
2609
+ // controller is mounted without them.
2610
+ _i18n(key) {
2611
+ const translations = {
2612
+ set_agent_error: this.element.dataset.topicSetAgentError || 'Unable to assign the agent to this topic.',
2613
+ agent_assigned_title: this.element.dataset.topicAgentAssignedTitle || '%{name} is assigned to this topic — click to release',
2614
+ clear_agent_confirm: this.element.dataset.topicClearAgentConfirm || 'Release %{name} from this topic? Every agent will be able to respond here again.',
2615
+ create_error: this.element.dataset.topicCreateError || 'Unable to create the topic.',
2616
+ update_error: this.element.dataset.topicUpdateError || 'Unable to update the topic.',
2617
+ delete_error: this.element.dataset.topicDeleteError || 'Unable to delete the topic.',
2618
+ archive_error: this.element.dataset.topicArchiveError || 'Unable to archive the topic.',
2619
+ restore_error: this.element.dataset.topicRestoreError || 'Unable to restore the topic.'
2620
+ }
2621
+ return translations[key] || key
2622
+ }
2623
+
2624
+ // Clicking the avatar of an assigned agent releases the assignment. The avatar
2625
+ // sits inside the topic tag, so the click must not also fall through to
2626
+ // selecting that topic.
2627
+ async clearTopicPrimaryAgent(event) {
2628
+ event.preventDefault()
2629
+ event.stopPropagation()
2630
+
2631
+ const topicId = event.currentTarget.dataset.id
2632
+ if (!topicId) return
2633
+
2634
+ const topic = (this.topics || []).find(t => String(t.id) === String(topicId))
2635
+ const agentName = topic?.primary_agent?.name || ''
2636
+
2637
+ const confirmed = await confirmDialog(
2638
+ this._i18n('clear_agent_confirm').replace('%{name}', agentName)
2639
+ )
2640
+ if (!confirmed) return
2641
+
2642
+ await this.setTopicPrimaryAgent(topicId, null)
2643
+ }
2644
+
2645
+ // Pass agent === null to release the topic's assignment.
1003
2646
  async setTopicPrimaryAgent(topicId, agent) {
1004
2647
  if (!this.creativeId) return
1005
2648
 
@@ -1010,16 +2653,26 @@ export default class extends Controller {
1010
2653
  'Content-Type': 'application/json',
1011
2654
  'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
1012
2655
  },
1013
- body: JSON.stringify({ agent_id: agent.id })
2656
+ body: JSON.stringify({ agent_id: agent ? agent.id : null })
1014
2657
  })
1015
2658
 
2659
+ const data = await response.json().catch(() => ({}))
2660
+
1016
2661
  if (!response.ok) {
1017
- const data = await response.json()
1018
- console.error('Failed to set primary agent:', data.error)
2662
+ alertDialog(data.error || this._i18n("set_agent_error"))
2663
+ return
1019
2664
  }
1020
- // Topic update comes via WebSocket broadcast
2665
+
2666
+ // Render the avatar from the response rather than waiting for the
2667
+ // WebSocket broadcast. A dropped broadcast (e.g. the topics channel
2668
+ // subscription was refused) would otherwise leave the avatar
2669
+ // invisible until the next page load. The broadcast still runs and
2670
+ // propagates the change to other connected users; re-applying it
2671
+ // here is a no-op merge.
2672
+ if (data.topic) this.updateTopicInList(data.topic)
1021
2673
  } catch (e) {
1022
2674
  console.error('Error setting primary agent', e)
2675
+ alertDialog(this._i18n("set_agent_error"))
1023
2676
  }
1024
2677
  }
1025
2678
 
@@ -1040,21 +2693,40 @@ export default class extends Controller {
1040
2693
 
1041
2694
  if (response.ok) {
1042
2695
  const topic = await response.json()
2696
+ this._topicScrollInterrupted = false
1043
2697
  this.currentTopicId = topic.id
1044
2698
  await this.flushSaveLastTopic(topic.id)
1045
2699
  await this.loadTopics()
1046
2700
  this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
1047
2701
  } else {
1048
- console.error('Failed to create topic with agent')
2702
+ // Dropping an agent that has no feedback access here is now
2703
+ // refused (it would mute the topic), and that is a user mistake
2704
+ // with a fix — sharing the creative. A console line would leave
2705
+ // the drop looking like it silently did nothing.
2706
+ const data = await response.json().catch(() => ({}))
2707
+ const message = data.errors?.[0] || data.error
2708
+ console.error('Failed to create topic with agent', message)
2709
+ alertDialog(message || this._i18n('create_error'))
1049
2710
  }
1050
2711
  } catch (e) {
1051
2712
  console.error('Error creating topic with agent', e)
2713
+ alertDialog(this._i18n('create_error'))
1052
2714
  }
1053
2715
  }
1054
2716
 
1055
- renderAgentAvatar(agent) {
2717
+ // When topicId is given the avatar doubles as the release control: clicking it
2718
+ // unassigns the agent from the topic. Pass null to render it as a plain badge
2719
+ // (viewers who cannot manage topics).
2720
+ renderAgentAvatar(agent, topicId = null) {
1056
2721
  const size = 16
1057
- let html = `<span class="avatar-wrapper topic-agent-avatar-wrapper" style="width:${size}px;height:${size}px;" title="${this.escapeAttr(agent.name)}">`
2722
+ const releasable = topicId !== null && topicId !== undefined
2723
+ const title = releasable
2724
+ ? this._i18n('agent_assigned_title').replace('%{name}', agent.name)
2725
+ : agent.name
2726
+ const releaseAttrs = releasable
2727
+ ? ` data-action="click->comments--topics#clearTopicPrimaryAgent" data-id="${this.escapeAttr(String(topicId))}" role="button"`
2728
+ : ''
2729
+ let html = `<span class="avatar-wrapper topic-agent-avatar-wrapper${releasable ? ' topic-agent-avatar-releasable' : ''}"${releaseAttrs} style="width:${size}px;height:${size}px;" title="${this.escapeAttr(title)}">`
1058
2730
  html += `<img src="${this.escapeAttr(agent.avatar_url)}" alt="" width="${size}" height="${size}" class="topic-agent-avatar" style="border-radius:50%;vertical-align:middle;">`
1059
2731
  if (agent.default_avatar) {
1060
2732
  html += `<span class="avatar-initial">${this.escapeAttr(agent.initial)}</span>`