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
@@ -4,10 +4,16 @@ import { attachBundleDragImage } from '../../utils/drag_bundle_image'
4
4
  import { renderMarkdownInContainer } from '../../lib/utils/markdown'
5
5
  import creativesApi from '../../lib/api/creatives'
6
6
  import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
7
- import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
7
+ import csrfFetch, { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
8
8
  import { alertDialog, confirmDialog } from '../../lib/utils/dialog'
9
+ import PrevMessageNavigator from './prev_message_navigator'
9
10
  // CommonPopup is now used via TopicSearchController (Stimulus)
10
11
 
12
+ // Gestures that mean the user moved the list themselves, invalidating the
13
+ // previous-message anchor. Only user input fires these — our own smooth scroll
14
+ // does not.
15
+ const PREV_MSG_USER_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown']
16
+
11
17
  export default class extends Controller {
12
18
  static targets = ['list']
13
19
 
@@ -20,8 +26,13 @@ export default class extends Controller {
20
26
  this.movingComments = false
21
27
  this.manualSearchQuery = null
22
28
  this.initialLoadComplete = false
29
+ this.highlightCreativeId = null
30
+ this._loadCommentsVersion = 0
31
+ this.markReadTimeout = null
32
+ this.prevMsgNavigator = new PrevMessageNavigator()
23
33
 
24
34
  this.handleScroll = this.handleScroll.bind(this)
35
+ this.handlePrevMsgUserInput = this.handlePrevMsgUserInput.bind(this)
25
36
  this.handleChange = this.handleChange.bind(this)
26
37
  this.handleClick = this.handleClick.bind(this)
27
38
  this.handleSubmit = this.handleSubmit.bind(this)
@@ -33,6 +44,9 @@ export default class extends Controller {
33
44
  this.handleStreamRender = this.handleStreamRender.bind(this)
34
45
 
35
46
  this.listTarget.addEventListener('scroll', this.handleScroll)
47
+ PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
48
+ this.listTarget.addEventListener(name, this.handlePrevMsgUserInput)
49
+ })
36
50
  this.listTarget.addEventListener('change', this.handleChange)
37
51
  this.listTarget.addEventListener('click', this.handleClick)
38
52
  this.listTarget.addEventListener('submit', this.handleSubmit)
@@ -43,7 +57,7 @@ export default class extends Controller {
43
57
  // If we have a creativeId from data attribute or parent (unlikely directly on list,
44
58
  // usually set via onPopupOpened), try loading.
45
59
  // If not, onPopupOpened will trigger it.
46
- if (this.element.dataset.creativeId) {
60
+ if (this.element.dataset.creativeId && this.element.dataset.docked !== 'true') {
47
61
  this.creativeId = this.element.dataset.creativeId
48
62
  this.loadInitialComments()
49
63
  }
@@ -62,19 +76,36 @@ export default class extends Controller {
62
76
  }
63
77
 
64
78
  handleTopicChange(event) {
79
+ const nextTopicId = event.detail.topicId
80
+ if (
81
+ this.currentTopicId !== undefined &&
82
+ String(this.currentTopicId || '') === String(nextTopicId || '')
83
+ ) return
84
+
65
85
  // During notifyChildControllers, topic loading fires change events before
66
86
  // onPopupOpened sets up highlightAfterLoad. Suppress these to avoid a
67
87
  // race where a non-highlight load overwrites the deep-link highlight load.
68
88
  if (this.suppressTopicChangeLoad) {
69
- this.currentTopicId = event.detail.topicId
89
+ this.flushPendingRead()
90
+ this.currentTopicId = nextTopicId
70
91
  return
71
92
  }
72
- this.currentTopicId = event.detail.topicId
93
+ this.flushPendingRead()
94
+ this.currentTopicId = nextTopicId
95
+ // A user-selected topic supersedes any outstanding deep-link window.
96
+ // The old request is discarded by the topic/version guards below, while
97
+ // this replacement load starts from the selected topic's latest messages.
98
+ this.highlightAfterLoad = null
99
+ this.highlightCreativeId = null
73
100
  this.resetToLatest()
74
101
  }
75
102
 
76
103
  disconnect() {
104
+ this.flushPendingRead({ keepalive: true })
77
105
  this.listTarget.removeEventListener('scroll', this.handleScroll)
106
+ PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
107
+ this.listTarget.removeEventListener(name, this.handlePrevMsgUserInput)
108
+ })
78
109
  this.listTarget.removeEventListener('change', this.handleChange)
79
110
  this.listTarget.removeEventListener('click', this.handleClick)
80
111
  this.listTarget.removeEventListener('submit', this.handleSubmit)
@@ -106,9 +137,19 @@ export default class extends Controller {
106
137
  }
107
138
 
108
139
  onPopupOpened({ creativeId, highlightId, topicId } = {}) {
140
+ this.flushPendingRead()
141
+ const normalizedCreativeId = String(creativeId || '')
142
+ const pendingHighlight = String(this.highlightCreativeId || '') === normalizedCreativeId
143
+ ? this.highlightAfterLoad
144
+ : null
109
145
  this.creativeId = creativeId
146
+ this.renderedAllTopicIds = null
147
+ this.renderedAllTopicWatermarks = null
148
+ this.renderedAllIncludesLegacy = false
149
+ this.initialLoadComplete = false
110
150
  // highlightId from popup args takes precedence, else fallback to URL param if first load
111
- this.highlightAfterLoad = highlightId || this.deepLinkCommentId
151
+ this.highlightAfterLoad = highlightId || pendingHighlight || this.deepLinkCommentId
152
+ this.highlightCreativeId = this.highlightAfterLoad ? normalizedCreativeId : null
112
153
 
113
154
  if (topicId !== undefined) {
114
155
  this.currentTopicId = topicId
@@ -124,6 +165,15 @@ export default class extends Controller {
124
165
  }
125
166
 
126
167
  onPopupClosed() {
168
+ this.flushPendingRead()
169
+ this._loadCommentsVersion += 1
170
+ this.creativeId = null
171
+ this.renderedAllTopicIds = null
172
+ this.renderedAllTopicWatermarks = null
173
+ this.renderedAllIncludesLegacy = false
174
+ this.highlightAfterLoad = null
175
+ this.highlightCreativeId = null
176
+ this.suppressTopicChangeLoad = false
127
177
  this.resetState()
128
178
  this.listTarget.innerHTML = ''
129
179
  this.initialLoadComplete = false
@@ -152,6 +202,10 @@ export default class extends Controller {
152
202
  if (!this.creativeId) return
153
203
  if (this.selection.size > 0) return
154
204
 
205
+ // The list is about to be replaced wholesale; any anchor we hold is stale.
206
+ this.prevMsgNavigator.reset()
207
+
208
+ const requestVersion = ++this._loadCommentsVersion
155
209
  const params = {}
156
210
  if (this.highlightAfterLoad) {
157
211
  params.around_comment_id = this.highlightAfterLoad
@@ -160,12 +214,19 @@ export default class extends Controller {
160
214
  const requestTopicId = this.currentTopicId || ""
161
215
  const requestCreativeId = this.creativeId
162
216
 
163
- this.fetchComments(params).then((html) => {
217
+ this.fetchComments(params, { loadVersion: requestVersion }).then((html) => {
164
218
  // Discard stale responses if creative or topic changed while fetching.
165
219
  // This prevents a race condition where switching creatives causes
166
220
  // the old creative's comments to overwrite the new creative's list.
221
+ if (requestVersion !== this._loadCommentsVersion) return
167
222
  if (this.creativeId !== requestCreativeId) return
168
- if (String(this.currentTopicId || "") !== String(requestTopicId)) return
223
+ // A server-resolved deep link moves currentTopicId off the topic this
224
+ // request asked for. That is this request's own answer, not a stale one,
225
+ // so the topic guard has to let it through — otherwise the list sits on
226
+ // "Loading..." forever for every comment link pointing outside the
227
+ // restored topic.
228
+ if (!this.isServerResolvedTopic(requestVersion) &&
229
+ String(this.currentTopicId || "") !== String(requestTopicId)) return
169
230
 
170
231
  this.listTarget.innerHTML = html
171
232
  this.listTarget.dataset.currentTopicId = this.currentTopicId || ""
@@ -177,6 +238,7 @@ export default class extends Controller {
177
238
  this.allNewerLoaded = false // We are likely in middle
178
239
  this.highlightComment(this.highlightAfterLoad)
179
240
  this.highlightAfterLoad = null
241
+ this.highlightCreativeId = null
180
242
  } else {
181
243
  // Standard load -> Scroll to bottom (latest)
182
244
  this.scrollToBottom()
@@ -188,26 +250,41 @@ export default class extends Controller {
188
250
  this.formController.focusTextarea()
189
251
  }
190
252
  this.element.dispatchEvent(new CustomEvent('comments--list:loaded', { bubbles: true }))
253
+ this.reportRenderedAllTopics()
191
254
  this.markCommentsRead()
192
255
 
193
256
  }).catch((error) => {
257
+ if (requestVersion !== this._loadCommentsVersion) return
258
+ if (this.creativeId !== requestCreativeId) return
259
+ if (!this.isServerResolvedTopic(requestVersion) &&
260
+ String(this.currentTopicId || "") !== String(requestTopicId)) return
194
261
  this.listTarget.innerHTML = `<div class="comments-list-error">${error.message}</div>`
195
262
  })
196
263
  }
197
264
 
265
+ // fetchComments stamps the version of the request whose own response carried
266
+ // an X-Topic-Id that moved the selection. Only that request may ignore the
267
+ // stale-topic guard; every other load compares unequal and stays guarded.
268
+ isServerResolvedTopic(requestVersion) {
269
+ return this._serverTopicRequestVersion !== undefined &&
270
+ this._serverTopicRequestVersion === requestVersion
271
+ }
272
+
198
273
  loadOlderComments() {
199
274
  if (this.loadingOlder || this.allOlderLoaded || !this.creativeId) return
200
275
  const minId = this.getMinId()
201
276
  if (!minId) return
202
277
 
278
+ const requestContext = this.paginationRequestContext()
203
279
  this.loadingOlder = true
204
280
 
205
281
  // Standard Column: Older messages are at Top.
206
282
  // We Prepend them.
207
283
  const currentScrollHeight = this.listTarget.scrollHeight
208
284
 
209
- this.fetchComments({ before_id: minId })
285
+ this.fetchComments({ before_id: minId }, { pagination: true })
210
286
  .then((html) => {
287
+ if (!this.isCurrentPaginationContext(requestContext)) return
211
288
  if (html.trim() === '') {
212
289
  this.allOlderLoaded = true
213
290
  return
@@ -215,6 +292,12 @@ export default class extends Controller {
215
292
  // Prepend to start (Visual Top)
216
293
  this.listTarget.insertAdjacentHTML('afterbegin', html)
217
294
  renderMarkdownInContainer(this.listTarget)
295
+ const addedTopic = this.recordRenderedAllTopicWatermarks(
296
+ this.listTarget.querySelectorAll('.comment-item'),
297
+ { includeNewTopics: true },
298
+ )
299
+ if (addedTopic) this.reportRenderedAllTopics()
300
+ this.markCommentsRead()
218
301
 
219
302
  // Restore scroll position
220
303
  const newScrollHeight = this.listTarget.scrollHeight
@@ -222,7 +305,7 @@ export default class extends Controller {
222
305
 
223
306
  })
224
307
  .finally(() => {
225
- this.loadingOlder = false
308
+ if (this.isCurrentPaginationContext(requestContext)) this.loadingOlder = false
226
309
  })
227
310
  }
228
311
 
@@ -234,10 +317,12 @@ export default class extends Controller {
234
317
  return
235
318
  }
236
319
 
320
+ const requestContext = this.paginationRequestContext()
237
321
  this.loadingNewer = true
238
322
 
239
- this.fetchComments({ after_id: maxId })
323
+ this.fetchComments({ after_id: maxId }, { pagination: true })
240
324
  .then((html) => {
325
+ if (!this.isCurrentPaginationContext(requestContext)) return
241
326
  if (html.trim() === '') {
242
327
 
243
328
  this.allNewerLoaded = true
@@ -246,13 +331,34 @@ export default class extends Controller {
246
331
  // Append to end (Visual Bottom)
247
332
  this.listTarget.insertAdjacentHTML('beforeend', html)
248
333
  renderMarkdownInContainer(this.listTarget)
334
+ const addedTopic = this.recordRenderedAllTopicWatermarks(
335
+ this.listTarget.querySelectorAll('.comment-item'),
336
+ { includeNewTopics: true },
337
+ )
338
+ if (addedTopic) this.reportRenderedAllTopics()
339
+ this.markCommentsRead()
249
340
  })
250
341
  .finally(() => {
251
- this.loadingNewer = false
342
+ if (this.isCurrentPaginationContext(requestContext)) this.loadingNewer = false
252
343
  })
253
344
  }
254
345
 
255
- fetchComments(params = {}) {
346
+ paginationRequestContext() {
347
+ return {
348
+ creativeId: this.creativeId,
349
+ topicId: this.currentTopicId || null,
350
+ loadVersion: this._loadCommentsVersion,
351
+ }
352
+ }
353
+
354
+ isCurrentPaginationContext({ creativeId, topicId, loadVersion }) {
355
+ return this.element.isConnected &&
356
+ this.creativeId === creativeId &&
357
+ (this.currentTopicId || null) === topicId &&
358
+ this._loadCommentsVersion === loadVersion
359
+ }
360
+
361
+ fetchComments(params = {}, { loadVersion, pagination = false } = {}) {
256
362
  const urlParams = new URLSearchParams(params)
257
363
  if (this.manualSearchQuery) {
258
364
  urlParams.set('search', this.manualSearchQuery)
@@ -277,7 +383,27 @@ export default class extends Controller {
277
383
  }
278
384
 
279
385
  const serverTopicId = response.headers.get("X-Topic-Id")
280
- if (serverTopicId !== null && serverTopicId !== undefined) {
386
+ const renderedTopicIds = response.headers.get("X-Rendered-Topic-Ids")
387
+ const renderedTopicWatermarks = response.headers.get("X-Rendered-Topic-Watermarks")
388
+ const superseded = loadVersion !== undefined && loadVersion !== this._loadCommentsVersion
389
+ // Only replace this snapshot for a full list load. Pagination requests
390
+ // happen later and may observe a topic-strip archive change without
391
+ // rendering that topic's existing history.
392
+ if (loadVersion !== undefined && !pagination && !superseded && !this.currentTopicId && renderedTopicIds !== null) {
393
+ this.renderedAllTopicIds = renderedTopicIds.split(',').filter(Boolean)
394
+ try {
395
+ this.renderedAllTopicWatermarks = renderedTopicWatermarks ? JSON.parse(renderedTopicWatermarks) : null
396
+ this.renderedAllIncludesLegacy = Boolean(this.renderedAllTopicWatermarks && Object.hasOwn(this.renderedAllTopicWatermarks, '_legacy'))
397
+ } catch (_error) {
398
+ this.renderedAllTopicWatermarks = null
399
+ this.renderedAllIncludesLegacy = false
400
+ }
401
+ }
402
+ // A load superseded while in flight must not retopic anything: its own HTML
403
+ // is dropped, so moving currentTopicId (and the strip, and the form) to its
404
+ // answer would leave the surviving load rendering into a selection it never
405
+ // asked for.
406
+ if (serverTopicId !== null && serverTopicId !== undefined && !pagination && !superseded) {
281
407
  // Server says we are in this topic.
282
408
  // If it differs from current, update state.
283
409
 
@@ -287,6 +413,12 @@ export default class extends Controller {
287
413
 
288
414
  if (currentStr !== serverStr) {
289
415
  this.currentTopicId = serverTopicId
416
+ // Tell loadInitialComments' stale-topic guard that this switch is the
417
+ // answer to the load it is still awaiting. Stamp the version of the
418
+ // request that actually carried the header, not the controller's
419
+ // latest: reading the latest would hand the exemption to a newer load
420
+ // that never asked for a topic switch.
421
+ this._serverTopicRequestVersion = loadVersion
290
422
  // Notify topics controller to update UI
291
423
  const event = new CustomEvent("comments--topics:update-selection", { detail: { topicId: serverTopicId } })
292
424
  window.dispatchEvent(event)
@@ -296,8 +428,15 @@ export default class extends Controller {
296
428
  // Deep link / around_comment_id resolution from server must win over
297
429
  // saved topic state for the current popup session.
298
430
  this.popupController.topicsController.setOverrideTopicId(serverTopicId)
299
- // Update UI and local state without dispatching change event (to avoid loop)
300
- this.popupController.topicsController.updateSelectionUI(serverTopicId)
431
+ // Go through selectTopic, not updateSelectionUI: a deep link can resolve
432
+ // to an archived topic, whose chip exists only while the archived section
433
+ // is expanded, and form_controller learns the active topic solely from the
434
+ // change event — without it a reply posts into the previously selected
435
+ // conversation. The reload this event would normally trigger is already
436
+ // ruled out: this.currentTopicId was set to serverTopicId just above, so
437
+ // handleTopicChange's equality guard returns before resetToLatest() can
438
+ // discard the highlight window.
439
+ this.popupController.topicsController.selectTopic(serverTopicId)
301
440
 
302
441
  // Also update data attribute for CSS scoping
303
442
  this.listTarget.dataset.currentTopicId = serverTopicId || ""
@@ -312,6 +451,7 @@ export default class extends Controller {
312
451
  }
313
452
 
314
453
  applySearchQuery(query) {
454
+ this.flushPendingRead()
315
455
  this.resetState()
316
456
  this.manualSearchQuery = query
317
457
  this.listTarget.innerHTML = this.element.dataset.loadingText || '<div class="loading-spinner">Loading...</div>'
@@ -334,9 +474,17 @@ export default class extends Controller {
334
474
  return parseInt(last.dataset.commentId)
335
475
  }
336
476
 
477
+ // Public seam for sibling controllers that scroll the list on their own
478
+ // (e.g. the review-quote chip). Lets them drop the prev-message anchor at the
479
+ // choke point without reaching into the navigator's internals.
480
+ notifyProgrammaticScroll() {
481
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
482
+ }
483
+
337
484
  highlightComment(commentId) {
338
485
  const comment = document.getElementById(`comment_${commentId}`)
339
486
  if (!comment) return
487
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
340
488
  comment.scrollIntoView({ behavior: 'auto', block: 'center' })
341
489
  comment.classList.add('highlight-flash')
342
490
  comment.dataset.highlighted = 'true'
@@ -345,18 +493,67 @@ export default class extends Controller {
345
493
 
346
494
  markCommentsRead() {
347
495
  if (!this.creativeId) return
348
- window.setTimeout(() => {
349
- fetch('/comment_read_pointers/update', {
350
- method: 'POST',
351
- headers: {
352
- 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content,
353
- 'Content-Type': 'application/json',
354
- },
355
- body: JSON.stringify({ creative_id: this.creativeId }),
356
- }).catch(() => { /* ignore — creative may have been deleted */ })
496
+ if (this.markReadTimeout) window.clearTimeout(this.markReadTimeout)
497
+ const creativeId = this.creativeId
498
+ const topicId = this.currentTopicId || null
499
+ const topicIds = topicId ? null : this.renderedAllTopicIds
500
+ const topicWatermarks = topicId ? null : this.renderedAllTopicWatermarks
501
+ const pendingRead = { creativeId, topicId, topicIds, topicWatermarks }
502
+ this.pendingRead = pendingRead
503
+ this.markReadTimeout = window.setTimeout(() => {
504
+ this.markReadTimeout = null
505
+ if (this.pendingRead !== pendingRead) return
506
+ this.pendingRead = null
507
+ if (!this.element.isConnected || this.creativeId !== creativeId || (this.currentTopicId || null) !== topicId) return
508
+
509
+ this.updateReadPointer(creativeId, topicId, topicIds, topicWatermarks)
357
510
  }, 2000);
358
511
  }
359
512
 
513
+ flushPendingRead({ keepalive = false } = {}) {
514
+ const pendingRead = this.pendingRead
515
+ if (!pendingRead) return
516
+
517
+ if (this.markReadTimeout) window.clearTimeout(this.markReadTimeout)
518
+ this.markReadTimeout = null
519
+ this.pendingRead = null
520
+ this.updateReadPointer(
521
+ pendingRead.creativeId,
522
+ pendingRead.topicId,
523
+ pendingRead.topicIds,
524
+ pendingRead.topicWatermarks,
525
+ { keepalive }
526
+ )
527
+ }
528
+
529
+ updateReadPointer(creativeId, topicId, topicIds = null, topicWatermarks = null, { keepalive = false } = {}) {
530
+ if (!creativeId) return
531
+
532
+ csrfFetch('/comment_read_pointers/update', {
533
+ method: 'POST',
534
+ headers: {
535
+ 'Content-Type': 'application/json',
536
+ },
537
+ keepalive,
538
+ body: JSON.stringify({
539
+ creative_id: creativeId,
540
+ topic_id: topicId,
541
+ ...(topicIds ? { topic_ids: topicIds } : {}),
542
+ ...(topicWatermarks ? { topic_watermarks: topicWatermarks } : {}),
543
+ }),
544
+ }).then((response) => {
545
+ if (!response.ok || !this.element.isConnected || this.creativeId !== creativeId || (this.currentTopicId || null) !== topicId) return
546
+
547
+ // A successful topic read changes its chip and the aggregate creative
548
+ // badge. Reload immediately instead of leaving stale counts on screen.
549
+ this.popupController?.topicsController?.loadTopics?.()
550
+ }).catch(() => { /* ignore — creative may have been deleted */ })
551
+ }
552
+
553
+ handlePrevMsgUserInput() {
554
+ this.prevMsgNavigator.notifyUserInput()
555
+ }
556
+
360
557
  handleScroll() {
361
558
  if (!this.initialLoadComplete) return
362
559
 
@@ -459,23 +656,104 @@ export default class extends Controller {
459
656
  // Actually form_controller handleSubmit calls this list controller? No, distinct.
460
657
  }
461
658
 
659
+ // A comment with no topic is never archived, so the empty check also keeps
660
+ // this off the topics controller for the common case.
661
+ isArchivedTopicMessage(topicId) {
662
+ if (!topicId) return false
663
+ return Boolean(this.popupController?.topicsController?.isArchivedTopic?.(topicId))
664
+ }
665
+
666
+ // All Messages sends a bounded per-topic snapshot so a later archive change
667
+ // cannot mark comments the list did not render as read. A visible live append
668
+ // extends that bound for its already-rendered topic before the read debounce
669
+ // captures it. Only pagination may add a new topic: its comment is already
670
+ // visible in the DOM. Live streams still fence topics outside the initial
671
+ // snapshot because older history for those topics may be unseen.
672
+ recordRenderedAllTopicWatermarks(comments, { includeNewTopics = false } = {}) {
673
+ if (this.currentTopicId || !Array.isArray(this.renderedAllTopicIds)) return false
674
+
675
+ let addedTopic = false
676
+ const renderedComments = comments instanceof Element ? [comments] : Array.from(comments || [])
677
+ renderedComments.forEach((comment) => {
678
+ const topicId = comment.dataset.topicId
679
+ const commentId = Number.parseInt(comment.dataset.commentId, 10)
680
+ if (!Number.isSafeInteger(commentId) || commentId <= 0) return
681
+
682
+ const watermarkKey = topicId || '_legacy'
683
+ if (!topicId && !this.renderedAllIncludesLegacy) {
684
+ if (!includeNewTopics) return
685
+
686
+ this.renderedAllIncludesLegacy = true
687
+ addedTopic = true
688
+ }
689
+
690
+ if (topicId && !this.renderedAllTopicIds.some((id) => String(id) === String(topicId))) {
691
+ if (!includeNewTopics) return
692
+
693
+ this.renderedAllTopicIds.push(String(topicId))
694
+ addedTopic = true
695
+ }
696
+
697
+ this.renderedAllTopicWatermarks ||= {}
698
+ const previousId = Number.parseInt(this.renderedAllTopicWatermarks[watermarkKey], 10)
699
+ if (!Number.isSafeInteger(previousId) || commentId > previousId) {
700
+ this.renderedAllTopicWatermarks[watermarkKey] = commentId
701
+ }
702
+ })
703
+
704
+ return addedTopic
705
+ }
706
+
707
+ reportRenderedAllTopics() {
708
+ if (this.currentTopicId || !Array.isArray(this.renderedAllTopicIds)) return
709
+
710
+ this.element.dispatchEvent(new CustomEvent('comments--list:rendered-all-topics', {
711
+ bubbles: true,
712
+ detail: {
713
+ creativeId: this.creativeId,
714
+ topicIds: this.renderedAllTopicIds,
715
+ includesLegacy: this.renderedAllIncludesLegacy,
716
+ },
717
+ }))
718
+ }
719
+
720
+ isOutsideRenderedAllTopics(topicId) {
721
+ return Array.isArray(this.renderedAllTopicIds) &&
722
+ !this.renderedAllTopicIds.some((id) => String(id) === String(topicId))
723
+ }
724
+
462
725
  handleStreamRender(event) {
463
726
  // Only care about streams targeting our list
464
727
  if (event.target.target !== 'comments-list') return
465
728
 
466
729
  // Deduplication: If manually appended by form_controller, block the stream echo.
730
+ let appendedComment = null
467
731
  if (event.target.action === 'append') {
468
732
  const templateContent = event.target.templateContent || event.target.querySelector('template')?.content
469
733
  const firstChild = templateContent?.firstElementChild
734
+ appendedComment = firstChild
470
735
 
471
- // Check for topic context mismatch
736
+ // Check for topic context mismatch. Runs ahead of the search block below:
737
+ // both block the append, but only this one badges, and the badge is the
738
+ // sole notice that an out-of-view conversation moved. Search suppressing
739
+ // it would hide an archived topic's traffic completely, since the archived
740
+ // section carries no other signal.
472
741
  if (firstChild && firstChild.dataset.topicId !== undefined) {
473
742
  const messageTopicId = firstChild.dataset.topicId
474
743
  const currentTopicId = this.currentTopicId || ""
475
744
 
476
745
  // If we are in a specific topic (currentTopicId is set)
477
- // AND the message is for a different topic
478
- if (currentTopicId && String(currentTopicId) !== String(messageTopicId)) {
746
+ // AND the message is for a different topic.
747
+ //
748
+ // All Messages (currentTopicId === "") takes everything except archived
749
+ // topics: CommentsController#index filters those out of this view, so
750
+ // letting a live one in would show a message that vanishes on reload.
751
+ // Both cases badge the topic instead of appending.
752
+ const isForeignTopic = currentTopicId
753
+ ? String(currentTopicId) !== String(messageTopicId)
754
+ : this.isArchivedTopicMessage(messageTopicId) || this.isOutsideRenderedAllTopics(messageTopicId)
755
+
756
+ if (isForeignTopic) {
479
757
  event.preventDefault()
480
758
  // Dispatch event for topics controller to show badge
481
759
  const customEvent = new CustomEvent("comments--topics:new-message", {
@@ -486,6 +764,17 @@ export default class extends Controller {
486
764
  }
487
765
  }
488
766
 
767
+ // A search-filtered list is the result set of a query, and the match runs
768
+ // server-side over the raw content. A live append carries no verdict on
769
+ // whether it matches, so letting it in drops an unrelated message into the
770
+ // results — and, when there were none, takes the "no results" notice with
771
+ // it (comments--placeholder clears on any .comment-item arriving). Blocked
772
+ // like history mode below; the next search or reset re-queries the server.
773
+ if (this.manualSearchQuery) {
774
+ event.preventDefault()
775
+ return
776
+ }
777
+
489
778
  if (firstChild && firstChild.id && document.getElementById(firstChild.id)) {
490
779
  event.preventDefault()
491
780
  return
@@ -501,7 +790,13 @@ export default class extends Controller {
501
790
  // Optional: Show a "New messages" indicator?
502
791
  // For now, strict requirement: "do not add to DOM".
503
792
  } else {
504
-
793
+ // The append is about to become visible. Mark it read after the existing
794
+ // debounce, so a reconnect or popup close cannot turn a viewed live
795
+ // message back into an unread one.
796
+ if (event.target.action === 'append') {
797
+ this.recordRenderedAllTopicWatermarks(appendedComment)
798
+ this.markCommentsRead()
799
+ }
505
800
  }
506
801
  }
507
802
 
@@ -696,9 +991,8 @@ export default class extends Controller {
696
991
  btnRect,
697
992
  (topic) => {
698
993
  if (topic.created) {
699
- // Topic was just created with comments moved — refresh
700
994
  this.clearSelection()
701
- this.loadInitialComments()
995
+ this.switchToTopic(topic.id)
702
996
  } else {
703
997
  this.handleMoveToTopic({ detail: { commentIds, targetTopicId: topic.id } })
704
998
  }
@@ -890,7 +1184,7 @@ export default class extends Controller {
890
1184
  if (!topicId) return
891
1185
  const topicsController = this.popupController?.topicsController
892
1186
  if (topicsController?.selectTopic) {
893
- topicsController.selectTopic(topicId)
1187
+ topicsController.selectTopic(topicId, { userInitiated: true })
894
1188
  } else {
895
1189
  this.currentTopicId = topicId
896
1190
  this.resetToLatest()
@@ -1078,28 +1372,16 @@ export default class extends Controller {
1078
1372
 
1079
1373
  scrollToPreviousMessage() {
1080
1374
  const list = this.listTarget
1081
- const items = Array.from(list.querySelectorAll('.comment-item'))
1082
- if (items.length === 0) return
1083
-
1084
- const listRect = list.getBoundingClientRect()
1085
- const viewportTop = listRect.top
1086
-
1087
- let currentIdx = -1
1088
- for (let i = 0; i < items.length; i++) {
1089
- const rect = items[i].getBoundingClientRect()
1090
- if (rect.top >= viewportTop - 2) {
1091
- currentIdx = i
1092
- break
1093
- }
1094
- }
1095
-
1096
- if (currentIdx === -1) currentIdx = items.length - 1
1375
+ const elements = Array.from(list.querySelectorAll('.comment-item'))
1376
+ if (elements.length === 0) return
1097
1377
 
1098
- const currentItem = items[currentIdx]
1099
- const currentRect = currentItem.getBoundingClientRect()
1100
- const isAtTop = Math.abs(currentRect.top - viewportTop) < 4
1378
+ const viewportTop = list.getBoundingClientRect().top
1379
+ const measured = elements.map((el) => ({
1380
+ id: el.dataset.commentId,
1381
+ top: el.getBoundingClientRect().top,
1382
+ }))
1101
1383
 
1102
- const targetIdx = isAtTop ? currentIdx - 1 : currentIdx
1384
+ const targetIdx = this.prevMsgNavigator.resolveTargetIndex(measured, viewportTop)
1103
1385
  if (targetIdx < 0) {
1104
1386
  if (!this.allOlderLoaded) {
1105
1387
  this.loadOlderComments()
@@ -1107,8 +1389,9 @@ export default class extends Controller {
1107
1389
  return
1108
1390
  }
1109
1391
 
1110
- const target = items[targetIdx]
1392
+ const target = elements[targetIdx]
1111
1393
  const targetTop = target.offsetTop - list.offsetTop
1394
+ this.prevMsgNavigator.commit(measured[targetIdx].id, measured[targetIdx].top)
1112
1395
  list.scrollTo({ top: targetTop, behavior: 'smooth' })
1113
1396
  this.stickToBottom = false
1114
1397
 
@@ -1126,6 +1409,9 @@ export default class extends Controller {
1126
1409
  }
1127
1410
 
1128
1411
  scrollToBottom() {
1412
+ // A jump to latest we did not initiate via the button - drop the anchor so
1413
+ // the next previous-message click resolves from here, not a stale target.
1414
+ this.prevMsgNavigator?.notifyProgrammaticScroll()
1129
1415
  // In column reverse, bottom of scroll might be tricky.
1130
1416
  // Easiest is to set scrollTop to a large value.
1131
1417
  requestAnimationFrame(() => {