collavre 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class AgentProvisioningController < ActionController::Base
5
+ SKILL_NAME = "collavre"
6
+ CONFIG_NAME = "collavre"
7
+ CACHE_CONTROL = "public, max-age=31536000, immutable"
8
+
9
+ rate_limit to: 60, within: 1.minute, only: :manifest
10
+
11
+ def manifest
12
+ response.headers["Cache-Control"] = "private, no-store"
13
+ workspace = find_workspace!
14
+ skill = Collavre::AgentProvisioning::Archive.collavre_skill
15
+ config = Collavre::AgentProvisioning::Archive.workspace_config(workspace, base_url: request.base_url)
16
+
17
+ render json: {
18
+ schema: "agent-provisioning/v1",
19
+ items: [
20
+ {
21
+ type: "skill",
22
+ name: SKILL_NAME,
23
+ url: absolute_url(agent_provision_skill_path(sha256: digest(skill))),
24
+ sha256: digest(skill)
25
+ },
26
+ {
27
+ type: "config",
28
+ name: CONFIG_NAME,
29
+ url: absolute_url(agent_provision_config_path(
30
+ agent_id: workspace.agent_id,
31
+ token: workspace.manifest_token,
32
+ sha256: digest(config)
33
+ )),
34
+ sha256: digest(config)
35
+ }
36
+ ]
37
+ }
38
+ end
39
+
40
+ def skill
41
+ bytes = Collavre::AgentProvisioning::Archive.collavre_skill
42
+ return head :not_found unless digest_matches?(bytes)
43
+
44
+ send_archive(bytes, "collavre.tar.gz")
45
+ end
46
+
47
+ def config_archive
48
+ workspace = find_workspace!
49
+ bytes = Collavre::AgentProvisioning::Archive.workspace_config(workspace, base_url: request.base_url)
50
+ return head :not_found unless digest_matches?(bytes)
51
+
52
+ send_archive(bytes, "collavre-config.tar.gz", cache_control: "private, no-store")
53
+ end
54
+
55
+ private
56
+
57
+ def find_workspace!
58
+ Collavre::AgentWorkspace.find_by_manifest_token!(agent_id: params[:agent_id], token: params[:token])
59
+ end
60
+
61
+ def digest(bytes)
62
+ Collavre::AgentProvisioning::Archive.sha256(bytes)
63
+ end
64
+
65
+ def absolute_url(path)
66
+ "#{request.base_url}#{path}"
67
+ end
68
+
69
+ def digest_matches?(bytes)
70
+ supplied = params[:sha256].to_s
71
+ supplied.bytesize == 64 && ActiveSupport::SecurityUtils.secure_compare(supplied, digest(bytes))
72
+ end
73
+
74
+ def send_archive(bytes, filename, cache_control: CACHE_CONTROL)
75
+ response.headers["Cache-Control"] = cache_control
76
+ send_data bytes, type: "application/gzip", disposition: "inline", filename: filename
77
+ end
78
+ end
79
+ end
@@ -95,7 +95,7 @@ module Collavre
95
95
  # row yet. Drop it up front — keyed by the ending session's id (sent by
96
96
  # the plugin, or derived from the required topic) — so this session's
97
97
  # own still-live row cannot masquerade as a live sibling and skip the
98
- # last-session teardown (which would pin routing_expression on and, if
98
+ # last-session teardown (which would leave the agent apparently live and, if
99
99
  # the WS close is never processed, leave nothing to clear it). Done
100
100
  # under the agent row lock to serialize against a concurrent subscribe/
101
101
  # unsubscribe on the shared agent — the same lock AgentChannel uses.
@@ -110,14 +110,8 @@ module Collavre
110
110
 
111
111
  unless sibling_sessions_live?(ai_user)
112
112
  last_session = true
113
- # Last (or only) session for this agent. Clear routing_expression
114
- # FIRST so Orchestration::Matcher#match_by_expression stops
115
- # dispatching new comments to this now-clientless agent while we
116
- # drain its task graph below; otherwise a comment arriving mid-drain
117
- # could enqueue a new task right after we cancelled the last one.
118
- # (Routing is reactivated on the next AgentChannel subscribe, not on
119
- # re-register.)
120
- ai_user.update_column(:routing_expression, nil) if ai_user.routing_expression.present?
113
+ # Last (or only) session for this agent. Its presence row was just
114
+ # removed, so Matcher stops dispatching to it before task cleanup.
121
115
  end
122
116
  end
123
117
 
@@ -133,13 +127,13 @@ module Collavre
133
127
  # Fail any tasks still delegated to this agent — including dispatches
134
128
  # routed to *work* topics outside the registration inbox. With no live
135
129
  # session left, agent_id uniquely scopes this agent's delegated work;
136
- # scoping by topic_id alone would leak the common case (a /work
137
- # dispatch on a project topic) until stuck detection times out.
130
+ # scoping by topic_id alone would leak the common case (a dispatch on a
131
+ # project topic) until stuck detection times out.
138
132
  cancel_delegated_tasks_for_session(ai_user)
139
133
  elsif topic
140
134
  # One shared agent fans out to many concurrent sessions (the default
141
135
  # AGENT_NAME case). Another session is still LIVE, so agent-wide
142
- # cleanup would clear the shared routing_expression and cancel the
136
+ # cleanup would cancel the
143
137
  # sibling's in-flight work — exactly the hazard AgentChannel#unsubscribed
144
138
  # guards against via presence. Tear down ONLY this ending session:
145
139
  # cancel work on its own topic (whose client is gone, so it would
@@ -162,55 +156,13 @@ module Collavre
162
156
  return
163
157
  end
164
158
 
165
- creative = topic.creative&.effective_origin
166
- unless creative
167
- render json: { error: "Creative not found" }, status: :not_found
168
- return
169
- end
170
-
171
- unless creative.has_permission?(current_user, :feedback)
172
- render json: { error: "Not authorized" }, status: :forbidden
173
- return
174
- end
175
-
176
- agent = resolve_reply_agent(topic, params[:task_id])
177
- unless agent
178
- render json: { error: "Not authorized" }, status: :forbidden
179
- return
180
- end
181
-
182
- # Atomically claim the delegated task BEFORE saving the reply.
183
- # Two concurrent /reply requests with the same task_id can both
184
- # pass resolve_reply_agent (which is a read-only scope check) and,
185
- # without an atomic WHERE status='delegated' transition, both would
186
- # save separate comments and both would run completion logic —
187
- # producing duplicate linked replies for one dispatch. Claim first,
188
- # save second, so the loser sees rows_updated == 0 and bails out
189
- # before touching the comments table.
190
- claimed_task = claim_delegated_task(agent, topic, params[:task_id])
191
- if params[:task_id].present? && claimed_task.nil?
192
- render json: { error: "Task already completed or not delegated" }, status: :conflict
193
- return
194
- end
195
-
196
- comment = creative.comments.build(
197
- content: params[:text].to_s,
198
- topic: topic,
199
- user: agent,
200
- skip_default_user: true,
201
- skip_dispatch: true
202
- )
203
-
204
- if comment.save
205
- task_claim_service.finalize(agent: agent, task: claimed_task, comment: comment) if claimed_task
206
- dispatch_a2a(agent, comment)
207
- render json: { comment_id: comment.id }, status: :created
208
- else
209
- # Restore the dispatch so the MCP client can retry — the failure
210
- # is text-level (validation), not task-level.
211
- claimed_task&.update!(status: "delegated")
212
- render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
213
- end
159
+ result = AiAgent::TaskReplyService.new(
160
+ topic: topic, current_user: current_user, text: params[:text], requested_task_id: params[:task_id],
161
+ agent_resolver: method(:resolve_reply_agent), task_claimer: method(:claim_delegated_task),
162
+ claim_service: task_claim_service
163
+ ).call
164
+ dispatch_a2a(result.agent, result.comment.reload, task: result.task) if result.comment
165
+ render json: result.body, status: result.status
214
166
  end
215
167
 
216
168
  # POST /api/v1/agent/notify
@@ -389,7 +341,7 @@ module Collavre
389
341
  # Mirrors resolve_reply_agent's task_id-first resolution but never
390
342
  # touches the task graph (notify only authorizes, it does not complete).
391
343
  # A native permission prompt can be raised during a dispatch that
392
- # selected this session via routing_expression on a *work* topic whose
344
+ # selected this live session on a *work* topic whose
393
345
  # primary_agent is unset or a different agent (the case documented on
394
346
  # resolve_reply_agent). The echoed active-dispatch task_id authorizes
395
347
  # the dispatched session agent directly; without it the topic-
@@ -419,7 +371,7 @@ module Collavre
419
371
  # Identify which Claude Channel agent this reply is for.
420
372
  #
421
373
  # Prefer the echoed task_id from the dispatch payload: the matcher can
422
- # route to a Claude Channel agent via routing_expression on a topic
374
+ # route to a live Claude Channel agent on a topic
423
375
  # whose primary_agent is unset or a different agent (e.g. multiple AI
424
376
  # agents share a topic, or this agent only has feedback permission on
425
377
  # the creative without being the topic's primary). In those cases the
@@ -495,20 +447,9 @@ module Collavre
495
447
 
496
448
  tracker = Orchestration::ResourceTracker.for(agent)
497
449
  tasks.find_each do |task|
498
- task.update!(status: "cancelled", pending_tool_call: nil)
499
- tracker.release!(task.id)
450
+ next unless task.cancel_if_active!(statuses: %w[delegated], pending_tool_call: nil)
500
451
 
501
- if task.parent_task_id.present?
502
- begin
503
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
504
- task, error_message: "Claude Channel session unregistered before reply"
505
- )
506
- rescue StandardError => e
507
- Rails.logger.error(
508
- "[AgentsController] fail_subtask! failed for task #{task.id}: #{e.message}"
509
- )
510
- end
511
- end
452
+ tracker.release!(task.id)
512
453
 
513
454
  Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
514
455
  end
@@ -526,8 +467,6 @@ module Collavre
526
467
  # Release the tracker slot defensively for running tasks — release! is
527
468
  # idempotent (Set#delete no-ops on missing key) and we don't know from
528
469
  # the DB whether AiAgentJob had already reached tracker.reserve!.
529
- # Parent subtasks are still failed so workflow executors don't deadlock
530
- # waiting for a reply that will never come.
531
470
  def cancel_pending_tasks_for_session(agent)
532
471
  cancel_pending_tasks(Task.where(agent_id: agent.id, status: %w[queued pending running]), agent)
533
472
  end
@@ -538,22 +477,12 @@ module Collavre
538
477
  tracker = Orchestration::ResourceTracker.for(agent)
539
478
  drained_topics = {}
540
479
  tasks.find_each do |task|
541
- was_running = task.status == "running"
542
- task.update!(status: "cancelled")
480
+ previous_status = task.cancel_if_active!(statuses: %w[queued pending running])
481
+ next unless previous_status
482
+
483
+ was_running = previous_status == "running"
543
484
  tracker.release!(task.id) if was_running
544
485
  drained_topics[task.topic_id] ||= task.creative_id
545
-
546
- if task.parent_task_id.present?
547
- begin
548
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
549
- task, error_message: "Claude Channel session unregistered before dispatch"
550
- )
551
- rescue StandardError => e
552
- Rails.logger.error(
553
- "[AgentsController] fail_subtask! failed for queued task #{task.id}: #{e.message}"
554
- )
555
- end
556
- end
557
486
  end
558
487
 
559
488
  # Drain each affected topic's queue once, AFTER all of this session's
@@ -570,17 +499,36 @@ module Collavre
570
499
  end
571
500
  end
572
501
 
573
- def dispatch_a2a(agent, comment)
502
+ def dispatch_a2a(agent, comment, task:)
574
503
  AiAgent::A2aDispatcher.new(
575
504
  agent: agent,
576
505
  reply_comment: comment,
577
- context: {
578
- "creative" => { "id" => comment.creative_id },
579
- "topic" => { "id" => comment.topic_id }
580
- }
506
+ context: { "creative" => { "id" => comment.creative_id },
507
+ "topic" => { "id" => comment.topic_id },
508
+ SystemEvents::Envelope::KEY => task&.trigger_event_payload&.dig(SystemEvents::Envelope::KEY)
509
+ }.compact,
510
+ workspace_user: workspace_user_for(task)
581
511
  ).dispatch
582
512
  end
583
513
 
514
+ def workspace_user_for(task)
515
+ unless task
516
+ return current_user unless current_user.ai_user?
517
+
518
+ return
519
+ end
520
+
521
+ payload = task.trigger_event_payload || {}
522
+ user =
523
+ if payload.key?("workspace_user_id")
524
+ User.find_by(id: payload["workspace_user_id"])
525
+ else
526
+ Comment.find_by(id: payload.dig("comment", "id"))&.user
527
+ end
528
+
529
+ user unless user&.ai_user?
530
+ end
531
+
584
532
  # Provisions the (agent, topic) identities for a Claude Code
585
533
  # registration. Memoized per request; behavior extracted verbatim.
586
534
  def session_provisioner
@@ -14,7 +14,8 @@ module Collavre
14
14
  private
15
15
 
16
16
  # If the original request was for "/" and the user is signed in,
17
- # honor SystemSetting.home_page_path_authenticated by redirecting.
17
+ # return them to their last readable Creative when the authenticated home
18
+ # page is the Creative index; otherwise honor the configured home page.
18
19
  # The middleware preserves "/" as a stable URL for unauthenticated
19
20
  # visitors; authenticated users get a real URL change so the address
20
21
  # bar reflects state.
@@ -31,13 +32,23 @@ module Collavre
31
32
  return unless request.env["collavre.root_request"]
32
33
  return unless authenticated?
33
34
 
34
- target = SystemSetting.home_page_path_authenticated
35
+ target = last_visited_creative_path || SystemSetting.home_page_path_authenticated
35
36
  return if target.blank?
36
37
  return if target == "/"
37
38
 
38
39
  redirect_to target
39
40
  end
40
41
 
42
+ def last_visited_creative_path
43
+ home_path = HomePagePath.mount_relative(script_name: request.script_name)
44
+ return unless home_path == "/creatives" || home_path == "/creatives.html"
45
+
46
+ creative = Current.user.last_visited_creative
47
+ return unless creative&.has_permission?(Current.user, :read)
48
+
49
+ collavre_engine.creatives_path(id: creative.id, script_name: request.script_name)
50
+ end
51
+
41
52
  def set_csrf_token_header
42
53
  return unless protect_against_forgery?
43
54
 
@@ -1,68 +1,97 @@
1
1
  module Collavre
2
2
  class CommentReadPointersController < ApplicationController
3
+ LEGACY_TOPIC_WATERMARK_KEY = "_legacy"
4
+
3
5
  def update
4
6
  creative = Creative.find(params[:creative_id]).effective_origin
5
- last_id = creative.comments.visible_to(Current.user).maximum(:id)
6
- pointer = CommentReadPointer.find_or_initialize_by(user: Current.user, creative: creative)
7
+ topic = params[:topic_id].present? && creative.topics.find_by(id: params[:topic_id])
8
+ return head :unprocessable_entity if params[:topic_id].present? && !topic
7
9
 
8
- previous_last_read_id = pointer.last_read_comment_id
9
- pointer.last_read_comment_id = last_id
10
- pointer.save!
10
+ last_ids = topic ? topic_last_id(creative, topic) : all_message_last_ids(creative)
11
+ positions = Comments::ReadPointerWriter.new(creative: creative, user: Current.user).call(last_ids)
12
+ Comments::ReadReceiptBroadcaster.new(creative: creative).call(positions)
11
13
 
12
- mark_inbox_items_read(creative, last_id)
13
14
  Comment.broadcast_badge(creative, Current.user)
14
15
 
15
- if previous_last_read_id && previous_last_read_id != last_id
16
- broadcast_read_receipts(creative, previous_last_read_id)
17
- end
18
- broadcast_read_receipts(creative, last_id)
19
-
20
16
  render json: { success: true }
17
+ rescue Comments::ReadPointerWriter::StaleTopicError
18
+ head :unprocessable_entity
21
19
  end
22
20
 
23
21
  private
24
22
 
25
- def broadcast_read_receipts(creative, comment_id)
26
- return unless comment_id
27
-
28
- # We map to the nearest PUBLIC comment to avoid leaking the existence/ID of private comments
29
- # via the public action cable channel.
30
- # Trade-off: Private-only threads (with no preceding public comment) will not get real-time read updates.
31
- effective_id = find_nearest_public_comment_id(creative, comment_id)
32
- return unless effective_id
23
+ def topic_last_id(creative, topic)
24
+ { topic.id => creative.comments.visible_to(Current.user).where(topic: topic).maximum(:id) }
25
+ end
33
26
 
34
- users = fetch_users_on_effective_id(creative, effective_id)
27
+ # All Messages renders Main plus active topics, not archived ones. Do not
28
+ # advance the retained legacy pointer here: an archived topic without its
29
+ # own pointer falls back to it, so moving that watermark would silently mark
30
+ # its hidden comments as read.
31
+ def all_message_last_ids(creative)
32
+ visible_comments = creative.comments.visible_to(Current.user)
33
+ topic_watermarks, legacy_watermark = normalized_topic_watermarks(params[:topic_watermarks])
34
+ return watermarked_last_ids(creative, visible_comments, topic_watermarks, legacy_watermark) if
35
+ topic_watermarks.any? || legacy_watermark.positive?
36
+
37
+ unwatermarked_last_ids(creative, visible_comments)
38
+ end
35
39
 
36
- present_user_ids = CommentPresenceStore.list(creative.id)
40
+ def normalized_topic_watermarks(rendered_topic_watermarks)
41
+ raw_watermarks = rendered_topic_watermarks.respond_to?(:to_unsafe_h) ? rendered_topic_watermarks.to_unsafe_h : rendered_topic_watermarks.to_h
42
+ topic_watermarks = raw_watermarks.filter_map do |topic_id, comment_id|
43
+ [ topic_id.to_i, comment_id.to_i ] if topic_id.to_i.positive? && comment_id.to_i.positive?
44
+ end.to_h
37
45
 
38
- Turbo::StreamsChannel.broadcast_update_to(
39
- [ creative, :comments ],
40
- target: "read_receipts_comment_#{effective_id}",
41
- partial: "collavre/comments/read_receipts",
42
- locals: { read_by_users: users, present_user_ids: present_user_ids }
43
- )
46
+ [ topic_watermarks, raw_watermarks[LEGACY_TOPIC_WATERMARK_KEY].to_i ]
44
47
  end
45
48
 
46
- def find_nearest_public_comment_id(creative, comment_id)
47
- creative.comments.public_only.where("id <= ?", comment_id).maximum(:id)
49
+ def watermarked_last_ids(creative, visible_comments, topic_watermarks, legacy_watermark)
50
+ last_ids = bounded_topic_last_ids(creative, visible_comments, topic_watermarks)
51
+ return last_ids unless legacy_watermark.positive?
52
+
53
+ legacy_last_id = visible_comments.where(topic_id: nil).where("comments.id <= ?", legacy_watermark).maximum(:id)
54
+ legacy_last_id ? last_ids.merge(nil => legacy_last_id) : last_ids
48
55
  end
49
56
 
50
- def fetch_users_on_effective_id(creative, effective_id)
51
- next_public_id = creative.comments.public_only.where("id > ?", effective_id).minimum(:id)
57
+ # Every rendered topic carries its own bound, so the maxima are OR-ed into a
58
+ # single grouped query rather than one MAX per topic. Built from relations
59
+ # rather than a SQL fragment: a hand-built string here would be safe (literal
60
+ # template, bound values) but unprovably so to a scanner.
61
+ def bounded_topic_last_ids(creative, visible_comments, topic_watermarks)
62
+ topic_ids = creative.topics.where(id: topic_watermarks.keys).pluck(:id)
63
+ return {} if topic_ids.empty?
64
+
65
+ scopes = topic_ids.map do |topic_id|
66
+ Comment.where(creative_id: creative.id, topic_id: topic_id)
67
+ .where(Comment.arel_table[:id].lteq(topic_watermarks.fetch(topic_id)))
68
+ end
69
+ maxima = grouped_topic_maxima(visible_comments, scopes)
70
+ topic_ids.index_with { |topic_id| maxima[topic_id] }
71
+ end
52
72
 
53
- query = CommentReadPointer.where(creative: creative)
54
- .where("last_read_comment_id >= ?", effective_id)
73
+ def unwatermarked_last_ids(creative, visible_comments)
74
+ rendered_topic_ids_supplied = params.key?(:topic_ids)
75
+ topics = rendered_topic_ids_supplied ? creative.topics.where(id: Array(params[:topic_ids])) : creative.topics
76
+ topic_ids = topics.pluck(:id)
77
+ maxima = visible_comments.where(topic_id: topic_ids).group(:topic_id).maximum(:id)
78
+ last_ids = topic_ids.index_with { |topic_id| maxima[topic_id] }
79
+ return last_ids if rendered_topic_ids_supplied
55
80
 
56
- query = query.where("last_read_comment_id < ?", next_public_id) if next_public_id
81
+ legacy_client_last_ids(visible_comments, last_ids)
82
+ end
57
83
 
58
- query.includes(user: { avatar_attachment: :blob }).map(&:user)
84
+ # Clients deployed before topic-scoped watermarks only send creative_id.
85
+ # Preserve the previous creative-wide endpoint semantics for them: every
86
+ # named topic, including archived topics, and the retained legacy lane
87
+ # advance through the visible history.
88
+ def legacy_client_last_ids(visible_comments, last_ids)
89
+ legacy_last_id = visible_comments.where(topic_id: nil).maximum(:id)
90
+ legacy_last_id ? last_ids.merge(nil => legacy_last_id) : last_ids
59
91
  end
60
92
 
61
- # No-op: inbox notifications are now comments on the user's inbox creative.
62
- # Reading comments on a creative updates the CommentReadPointer, which
63
- # automatically handles the unread count for the inbox badge.
64
- def mark_inbox_items_read(_creative, _last_comment_id)
65
- # Intentionally empty — kept for backward compatibility during transition.
93
+ def grouped_topic_maxima(visible_comments, scopes)
94
+ visible_comments.merge(scopes.reduce { |combined, scope| combined.or(scope) }).group(:topic_id).maximum(:id)
66
95
  end
67
96
  end
68
97
  end