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
@@ -8,6 +8,84 @@ module Collavre
8
8
  # matches the same prefix to remove them once the waiter is dequeued.
9
9
  WAITING_NOTICE_PREFIX = "⏳"
10
10
 
11
+ # What a waiting notice speaks for, written by the door that posted it.
12
+ # "topic" is the deduplicated notice coalescing allows a topic exactly one
13
+ # of; "task" is the per-deferral notice the opt-out posts, which names its
14
+ # waiter in waiting_notice_task_id. nil is a notice from before this was
15
+ # recorded — see #cancel_queued_tasks_for_waiting_notice.
16
+ WAITING_NOTICE_TOPIC = "topic"
17
+ WAITING_NOTICE_TASK = "task"
18
+
19
+ # Take down the per-deferral notice that speaks for this waiter, if it had
20
+ # one.
21
+ #
22
+ # A "task" notice is on screen for exactly as long as its waiter is queued:
23
+ # it names that waiter in waiting_notice_task_id and
24
+ # #cancel_queued_tasks_for_waiting_notice stops that waiter and nothing
25
+ # else. So the moment the waiter leaves the queue the notice is a stop
26
+ # button for work that cannot be stopped — and no sweep will collect it,
27
+ # since the drained sweep only runs when the topic queue empties and the
28
+ # waiter itself will never be promoted again.
29
+ #
30
+ # It therefore comes down wherever the waiter leaves, which is why this
31
+ # lives here beside the columns rather than in one of those callers: the
32
+ # promotion (AgentOrchestrator.cleanup_waiter_notice!) and the fold
33
+ # (Orchestration::TaskCoalescer) are two doors onto the same rule, and a
34
+ # third would otherwise write its own copy or forget.
35
+ #
36
+ # The destroy is marked as a system removal: the waiter is being promoted or
37
+ # folded, not abandoned by the user, so the cancellation callback must not
38
+ # fire on top of it.
39
+ def self.remove_waiter_notices!(creative_id:, topic_id:, task_ids:)
40
+ task_ids = Array(task_ids).compact
41
+ return if task_ids.empty?
42
+
43
+ where(creative_id: creative_id, topic_id: topic_id, user_id: nil,
44
+ waiting_notice_scope: WAITING_NOTICE_TASK,
45
+ waiting_notice_task_id: task_ids).find_each do |notice|
46
+ notice.suppress_waiter_cancellation = true
47
+ notice.destroy
48
+ end
49
+ end
50
+
51
+ # Take down every "⏳" defer notice in the topic that no longer speaks for a
52
+ # queued waiter.
53
+ #
54
+ # Asked per notice, not per topic. "Is anything queued here?" is a topic-wide
55
+ # question and no notice asks it: a "task" notice speaks for the one waiter
56
+ # it names, and a shared one for every queued waiter *except* those a
57
+ # surviving "task" notice claims — see #represented_queued_waiters, which
58
+ # this and the stop button both go through so the two cannot drift. A topic
59
+ # can therefore hold waiters while a notice standing in it represents none
60
+ # of them, and that notice is a "⏳" line whose stop button selects nothing.
61
+ #
62
+ # Nothing else collects it. A cancelled waiter is never promoted, so the
63
+ # promotion's cleanup never runs for it, and a promotion that leaves only
64
+ # claimed waiters behind is exactly the case a topic-wide drain check reads
65
+ # as "still busy".
66
+ #
67
+ # Scoped to topic_concurrency_defer notices. A :delayed notice shares the
68
+ # prefix but explains a rate-limited or busy dispatch that is still going to
69
+ # run, so it speaks for no waiter by design and is not this sweep's to take.
70
+ #
71
+ # Asked under the same lock the notice doors take, so "nobody is queued" is
72
+ # a deferral's own before-or-after rather than a guess: it either commits
73
+ # its waiter before this reads, and keeps its notice, or after, and posts
74
+ # one of its own.
75
+ def self.remove_stranded_waiting_notices!(creative_id:, topic_id:)
76
+ transaction do
77
+ Collavre::Orchestration::TopicSlot.lock!(topic_id, creative_id)
78
+
79
+ where(creative_id: creative_id, topic_id: topic_id, user_id: nil,
80
+ topic_concurrency_defer: true).find_each do |notice|
81
+ next if notice.represented_queued_waiters.any?
82
+
83
+ notice.suppress_waiter_cancellation = true
84
+ notice.destroy
85
+ end
86
+ end
87
+ end
88
+
11
89
  # Use non-namespaced partial path for backward compatibility
12
90
  def to_partial_path
13
91
  "comments/comment"
@@ -83,7 +161,6 @@ module Collavre
83
161
  has_many :comment_reactions, class_name: "Collavre::CommentReaction", dependent: :destroy
84
162
  has_many :comment_versions, class_name: "Collavre::CommentVersion", dependent: :destroy
85
163
  has_many :review_versions, class_name: "Collavre::CommentVersion", foreign_key: :review_comment_id, dependent: :nullify
86
- has_many :inbox_items, class_name: "Collavre::InboxItem", dependent: :nullify
87
164
  has_many :quoting_comments, class_name: "Collavre::Comment", foreign_key: :quoted_comment_id, dependent: :destroy
88
165
  has_one :snapshot_as_result, class_name: "Collavre::CommentSnapshot", foreign_key: :result_comment_id, dependent: :nullify
89
166
  belongs_to :selected_version, class_name: "Collavre::CommentVersion", optional: true
@@ -97,6 +174,8 @@ module Collavre
97
174
 
98
175
  attribute :skip_default_user, :boolean, default: false
99
176
  attribute :skip_dispatch, :boolean, default: false
177
+ attribute :skip_link_preview, :boolean, default: false
178
+ attribute :skip_notification_revision, :boolean, default: false
100
179
  # Set by AgentOrchestrator.cleanup_waiting_notices! so destroying a notice as
101
180
  # part of *promoting* a waiter does not run the user-delete cancel cascade
102
181
  # (which would cancel other still-queued waiters in the same topic).
@@ -104,8 +183,8 @@ module Collavre
104
183
 
105
184
  before_validation :use_origin_creative
106
185
  before_validation :assign_default_user, on: :create
107
- before_validation :assign_main_topic, on: :create
108
- before_save :apply_link_previews, if: :should_apply_link_previews?
186
+ include TopicMembership
187
+ after_commit :enqueue_link_preview, on: [ :create, :update ], if: :link_preview_enqueue_required?
109
188
  after_create_commit :dispatch_to_orchestration
110
189
  after_create_commit :resume_trigger_loop_if_awaiting
111
190
 
@@ -123,6 +202,24 @@ module Collavre
123
202
  quoted_comment_id.present? && !review_type_question?
124
203
  end
125
204
 
205
+ # Which of `ids` are review requests, in one query. The orchestration paths
206
+ # that decide whether a trigger may be folded away or moved forward hold
207
+ # comment *ids*, not records, and run over a whole burst at once.
208
+ #
209
+ # `review_type: [nil, review]` rather than `where.not(question)`: the column
210
+ # is NULL for an ordinary review (only /compress-style questions set it), and
211
+ # SQL's `review_type != 1` drops NULL rows — which is every real review.
212
+ def self.review_message_ids(ids)
213
+ ids = Array(ids).compact.map(&:to_i).uniq
214
+ return [] if ids.empty?
215
+
216
+ review_messages.where(id: ids).pluck(:id)
217
+ end
218
+
219
+ scope :review_messages, -> {
220
+ where.not(quoted_comment_id: nil).where(review_type: [ nil, review_types[:review] ])
221
+ }
222
+
126
223
  # public for db migration
127
224
  def creative_snippet
128
225
  creative.creative_snippet
@@ -160,6 +257,8 @@ module Collavre
160
257
  end
161
258
 
162
259
  def cancel_pending_tasks
260
+ stranded_scopes = []
261
+
163
262
  # Cancel tasks triggered by this comment (no creative_id scoping —
164
263
  # CommentMoveService can change comment.creative_id without updating
165
264
  # existing tasks, so scoping would miss moved-comment tasks).
@@ -169,9 +268,35 @@ module Collavre
169
268
  Task.where(status: %w[pending running queued delegated]).find_each do |task|
170
269
  next unless task.trigger_event_payload&.dig("comment", "id") == id
171
270
 
271
+ # An un-started task can be the survivor of a coalesced burst, answering
272
+ # several comments at once. Cancelling it because its anchor was deleted
273
+ # would throw away the absorbed comments too — they have no task of their
274
+ # own left (TaskCoalescer cancelled those) and no other delivery path
275
+ # (session-backed agents receive only the trigger). Re-anchor onto the
276
+ # newest surviving merged comment instead; only a task with nothing left
277
+ # to say is cancelled.
278
+ next if reanchor_coalesced_task(task)
279
+
172
280
  was_delegated = task.status == "delegated"
173
281
  task.update!(status: "cancelled")
174
282
 
283
+ # A waiter cancelled here leaves the queue without ever being promoted,
284
+ # exactly as a folded one does — so the notice that spoke for it is left
285
+ # naming a task no longer queued, and its stop button cancels nothing.
286
+ # This door needs no policy change at all: the opt-out posts the notice,
287
+ # deleting the prompt cancels the waiter, and a sibling still parked
288
+ # keeps the drained sweep from ever running.
289
+ Comment.remove_waiter_notices!(
290
+ creative_id: task.creative_id, topic_id: task.topic_id, task_ids: task.id
291
+ )
292
+
293
+ # …and with coalescing on there is no per-deferral notice to take down:
294
+ # the waiter's signal is the topic's *shared* one, which the call above
295
+ # deliberately leaves alone. If this cancellation was the last thing that
296
+ # notice spoke for, nothing else will ever collect it. Swept after the
297
+ # loop, once every task this deletion cancels has left the queue.
298
+ stranded_scopes << [ task.creative_id, task.topic_id ]
299
+
175
300
  # Delegated tasks live past their job: the AiAgentJob already returned,
176
301
  # holding the agent slot under task.id and counting against the per-topic
177
302
  # serializer. Mirror the cancel path used elsewhere to free both.
@@ -179,17 +304,6 @@ module Collavre
179
304
  if task.agent
180
305
  Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
181
306
  end
182
- if task.parent_task_id.present?
183
- begin
184
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).fail_subtask!(
185
- task, error_message: "Triggering comment was deleted"
186
- )
187
- rescue StandardError => e
188
- Rails.logger.error(
189
- "[Comment#cancel_pending_tasks] fail_subtask! failed for task #{task.id}: #{e.message}"
190
- )
191
- end
192
- end
193
307
  Collavre::Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
194
308
  end
195
309
 
@@ -202,16 +316,215 @@ module Collavre
202
316
  # waiter is being advanced, not abandoned, and cancelling other still-queued
203
317
  # waiters would drop their work (multi-slot orphan recovery must not cancel
204
318
  # the rest).
319
+ stranded_scopes.uniq.each do |cancelled_creative_id, cancelled_topic_id|
320
+ next unless cancelled_creative_id
321
+
322
+ Comment.remove_stranded_waiting_notices!(
323
+ creative_id: cancelled_creative_id, topic_id: cancelled_topic_id
324
+ )
325
+ end
326
+
205
327
  if waiting_notice? && topic_concurrency_defer? && !suppress_waiter_cancellation
206
328
  cancel_queued_tasks_for_waiting_notice
207
329
  end
208
330
  end
209
331
 
332
+ # Move a coalesced task off this (deleted) comment and onto the newest of
333
+ # the comments it had absorbed. Returns true when the task was rescued, so
334
+ # the caller skips cancellation.
335
+ #
336
+ # Only un-started tasks: a `running`/`delegated` task has already been handed
337
+ # its payload, so re-anchoring changes nothing and deleting the prompt must
338
+ # still stop the turn.
339
+ def reanchor_coalesced_task(task)
340
+ return false unless %w[queued pending].include?(task.status)
341
+
342
+ Task.transaction { reanchor_locked_task(task) }
343
+ rescue ActiveRecord::RecordNotFound
344
+ # The task went away between the scan and the lock (a cascading delete).
345
+ # Nothing to rescue, and nothing left to cancel either.
346
+ false
347
+ end
348
+
349
+ # The lock body. `task` is the object cancel_pending_tasks' scan loaded, and
350
+ # everything derived here has to come from the row as it stands *now*:
351
+ # TaskCoalescer folds a sibling into this same task under a lock this path
352
+ # never took, so a fold committing between that scan and the write below
353
+ # would be overwritten by the pre-fold payload — and the absorbed sibling is
354
+ # already cancelled, so its comment has no task left to answer it.
355
+ #
356
+ # lock! reloads in place rather than into a second object: the caller keeps
357
+ # using this instance (it cancels the task when we return false), so swapping
358
+ # identity here would hand it stale attributes.
359
+ #
360
+ # Status is re-read for the same reason. A snapshot that said `queued` may
361
+ # have been promoted and started since, and a started turn has already been
362
+ # handed its payload — deleting the prompt must stop it, not re-target it.
363
+ def reanchor_locked_task(task)
364
+ task.lock!
365
+ return false unless %w[queued pending].include?(task.status)
366
+
367
+ payload = task.trigger_event_payload || {}
368
+ merged = Array(payload[Collavre::Orchestration::TaskCoalescer::PAYLOAD_KEY])
369
+ .compact.map(&:to_i).uniq - [ id ]
370
+ return false if merged.empty?
371
+
372
+ # Anything else in the merge window may have been deleted too. Newest by id:
373
+ # created_at is stamped per writing process, so a burst can carry skewed or
374
+ # tied timestamps (same reason MergedTriggerComments orders by id).
375
+ #
376
+ # Scoped to this turn's creative/topic: a merged comment moved elsewhere
377
+ # while the task waited (CommentMoveService#perform_move reassigns
378
+ # creative_id) is no longer part of it, and adopting it as the anchor would
379
+ # point the reply at a creative the agent may have no share on.
380
+ #
381
+ # The turn is the TASK's scope, not this comment's. cancel_pending_tasks
382
+ # looks tasks up with no creative scoping precisely because a move rewrites
383
+ # the comment without touching the task it triggered — so an anchor moved
384
+ # away and then deleted would be searching the creative it was moved *to*,
385
+ # find nothing there, and cancel a turn whose absorbed comments are all
386
+ # still sitting in the creative the task belongs to.
387
+ #
388
+ # Asked through MergedTriggerComments.in_turn, the same predicate that
389
+ # decides what the turn delivers. Eligibility is not only about *where* a
390
+ # comment is: a comment made private (or turned into an approval surface)
391
+ # after it was absorbed is one dispatch_to_orchestration would refuse to
392
+ # trigger on, and in_turn already drops it from the merged blocks. The
393
+ # anchor is delivered as the trigger itself, with no filter in front of
394
+ # it, so a lookup of its own here is how withdrawn content reaches the
395
+ # agent through the one door that never filters.
396
+ in_scope = Collavre::AiAgent::MergedTriggerComments
397
+ .in_turn(merged, "creative" => { "id" => task.creative_id },
398
+ "topic" => { "id" => task.topic_id })
399
+ .order(:id).to_a
400
+ replacement = in_scope.last
401
+ return false unless replacement
402
+
403
+ # Through the same door the refresh uses, so the promotion is recorded as
404
+ # an acquired anchor either way: this task was created to answer the
405
+ # comment being deleted, not this one.
406
+ payload = Collavre::Orchestration::TaskCoalescer.reanchor_payload(payload, replacement)
407
+ payload = payload.merge(
408
+ # Rebuilt from the in-scope ids rather than merged through
409
+ # absorb_into_payload, which would fold the out-of-scope ones straight
410
+ # back in from the payload it starts from. The new anchor is excluded so
411
+ # the promoted comment is not delivered twice.
412
+ Collavre::Orchestration::TaskCoalescer::PAYLOAD_KEY =>
413
+ (in_scope.map(&:id) - [ replacement.id ]).sort
414
+ )
415
+ task.update!(trigger_event_payload: payload)
416
+
417
+ Rails.logger.info(
418
+ "[Comment#cancel_pending_tasks] Re-anchored coalesced task #{task.id} from deleted " \
419
+ "comment #{id} to comment #{replacement.id}"
420
+ )
421
+ true
422
+ end
423
+
424
+ # Cancel *every* queued waiter this notice speaks for, not just the newest.
425
+ #
426
+ # Cancelling one was right while each deferral posted its own notice: the
427
+ # newest queued task was the one that notice belonged to. A topic now gets
428
+ # exactly one deduplicated notice
429
+ # (Orchestration::AgentOrchestrator.with_deduped_topic_notice), and with
430
+ # topic_max_concurrent_jobs > 1 it can stand for waiters from several agents
431
+ # — coalescing folds same-agent siblings only. Deleting it while cancelling
432
+ # one of them leaves the rest queued with nothing on screen representing
433
+ # them and no stop control, and nothing reposts a notice until the next
434
+ # deferral happens by.
435
+ #
436
+ # …but only a notice that actually *was* deduplicated. With
437
+ # coalesce_pending_tasks off, post_waiting_notice deliberately skips the
438
+ # dedup path and posts one notice per deferral: there the 1:1 still holds and
439
+ # cancelling every queued task would let a user discard unrelated waiters by
440
+ # dismissing one notice, defeating the opt-out.
441
+ #
442
+ # Which kind this is comes off the row (waiting_notice_scope), written by
443
+ # whichever door posted it. It used to be inferred from whether a sibling
444
+ # notice still stood, on the reasoning that the dedup path allows a topic
445
+ # exactly one — but the two kinds coexist as soon as the policy differs
446
+ # between agents or changes while a topic still has waiters, and then the
447
+ # inference inverts: the shared notice reads as a per-deferral one and takes
448
+ # down the newest queued waiter, which is very likely the one the *other*
449
+ # notice speaks for. A row cannot be classified by its neighbours when the
450
+ # neighbours are a different kind.
451
+ #
452
+ # Queued only. A task holding the topic slot (pending/running/…) is not
453
+ # waiting on anything; the notice surfaces it separately as
454
+ # topic_blocking_task, with its own stop button.
210
455
  def cancel_queued_tasks_for_waiting_notice
456
+ represented_queued_waiters.each { |task| task.update!(status: "cancelled") }
457
+
458
+ # Cancelling is the other way a topic queue empties, and it has no
459
+ # promotion behind it to run the drained check. The notices that spoke for
460
+ # the waiters just cancelled went with them — this one is being destroyed,
461
+ # and a per-deferral sibling names a task that is now cancelled — but a
462
+ # *shared* notice is only ever taken down by that check, so without this
463
+ # it is left on screen describing a wait that is over.
464
+ Comment.remove_stranded_waiting_notices!(creative_id: creative_id, topic_id: topic_id)
465
+ end
466
+
467
+ # The queued waiters this notice speaks for — what its stop button cancels,
468
+ # and equally what keeps it on screen. Public because
469
+ # .remove_stranded_waiting_notices! asks the same question from the other
470
+ # side: a notice representing nobody is a stop control for work that can no
471
+ # longer be stopped. Two answers to one question is how the button and the
472
+ # sweep would come to disagree about what a notice is for.
473
+ public def represented_queued_waiters
474
+ case waiting_notice_scope
475
+ when WAITING_NOTICE_TASK
476
+ # Speaks for exactly the waiter it was posted with — and for nothing at
477
+ # all once that waiter has been folded away or promoted.
478
+ queued_topic_waiters.select { |task| task.id == waiting_notice_task_id }
479
+ when WAITING_NOTICE_TOPIC
480
+ # Every queued waiter in the topic except those a per-deferral notice
481
+ # speaks for: the shared notice was never their signal, and their own
482
+ # stop control is — or is about to be — on screen.
483
+ #
484
+ # Asked of the waiter, not of the notices standing beside it. A waiter and
485
+ # its notice commit in two steps, so an opted-out waiter is queued before
486
+ # any notice names it; classifying it by what survives in that window
487
+ # cancels a turn that was only deferred, and leaves no notice behind to
488
+ # say so. What speaks for a waiter is settled when it is parked.
489
+ claimed = sibling_notice_waiter_ids
490
+ queued_topic_waiters.reject { |task| task_claims_own_notice?(task, claimed) }
491
+ else
492
+ # Posted before this was recorded. Nothing on the row says which kind it
493
+ # was, so keep the behaviour those notices were created under rather than
494
+ # guess: widening it would discard work, narrowing it would disarm the
495
+ # only stop control an in-flight wait has.
496
+ queued_topic_waiters.first(1)
497
+ end
498
+ end
499
+
500
+ def queued_topic_waiters
211
501
  scope = Task.where(status: "queued", creative_id: creative_id)
212
502
  scope = topic_id ? scope.where(topic_id: topic_id) : scope.where(topic_id: nil)
213
- task = scope.order(created_at: :desc).first
214
- task&.update!(status: "cancelled")
503
+ scope.order(created_at: :desc).to_a
504
+ end
505
+
506
+ # Does this waiter have a per-deferral notice of its own, rather than being
507
+ # one the topic's shared notice speaks for?
508
+ #
509
+ # The waiter says so itself, recorded when it was parked. Rows parked before
510
+ # that was recorded say nothing, so they keep the answer they were parked
511
+ # under — the surviving sibling notices — rather than being guessed at in
512
+ # either direction: widening this discards work, narrowing it disarms the only
513
+ # stop control an in-flight wait has.
514
+ def task_claims_own_notice?(task, claimed)
515
+ case task.waiting_notice_scope
516
+ when WAITING_NOTICE_TASK then true
517
+ when WAITING_NOTICE_TOPIC then false
518
+ else claimed.include?(task.id)
519
+ end
520
+ end
521
+
522
+ # Waiter ids a still-standing per-deferral notice represents. Runs
523
+ # after_destroy_commit, so this notice is already out of the query.
524
+ def sibling_notice_waiter_ids
525
+ Comment.where(creative_id: creative_id, topic_id: topic_id, user_id: nil,
526
+ waiting_notice_scope: WAITING_NOTICE_TASK)
527
+ .pluck(:waiting_notice_task_id).compact
215
528
  end
216
529
 
217
530
  def dispatch_to_orchestration
@@ -252,7 +565,7 @@ module Collavre
252
565
  # when the local Claude TUI answered the prompt, leaving pending_tool_call
253
566
  # set on the server for the rest of a locally-approved tool run.
254
567
 
255
- SystemEvents::Dispatcher.dispatch("comment_created", dispatch_payload)
568
+ SystemEvents::Dispatcher.dispatch("comment_created", dispatch_payload, source: "comment_callback")
256
569
  rescue StandardError => e
257
570
  Rails.logger.error(
258
571
  "[Comment#dispatch_to_orchestration] Failed for comment #{id}: " \
@@ -332,14 +645,6 @@ module Collavre
332
645
  )
333
646
  end
334
647
 
335
- def assign_main_topic
336
- return if topic_id.present?
337
- return unless creative
338
-
339
- fallback = user || Collavre.current_user || creative.user
340
- self.topic = creative.main_topic(fallback_user: fallback)
341
- end
342
-
343
648
  def use_origin_creative
344
649
  return unless creative
345
650
  self.creative = creative.effective_origin
@@ -352,14 +657,14 @@ module Collavre
352
657
  errors.add(:creative, "must be an origin creative")
353
658
  end
354
659
 
355
- def should_apply_link_previews?
356
- will_save_change_to_content? && content.present?
660
+ def enqueue_link_preview
661
+ return if content.blank?
662
+
663
+ CommentLinkPreviewJob.perform_later(id, content, notification_revision)
357
664
  end
358
665
 
359
- def apply_link_previews
360
- self.content = CommentLinkFormatter.new(content).format
361
- rescue StandardError => e
362
- Rails.logger.warn("Comment link preview formatting failed: #{e.class} #{e.message}")
666
+ def link_preview_enqueue_required?
667
+ saved_change_to_content? && !skip_link_preview
363
668
  end
364
669
 
365
670
  def images_must_be_images
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentNotificationDelivery < ApplicationRecord
5
+ self.table_name = "comment_notification_deliveries"
6
+
7
+ CLAIM_TIMEOUT = 5.minutes
8
+
9
+ validates :delivery_key, :recipient_id, :message, presence: true
10
+
11
+ scope :ready_for_push, -> {
12
+ where(push_enqueued_at: nil)
13
+ .where("push_claimed_at IS NULL OR push_claimed_at < ?", CLAIM_TIMEOUT.ago)
14
+ }
15
+
16
+ def enqueue_push!
17
+ claim_token = SecureRandom.uuid
18
+ claimed_at = Time.current
19
+ claimed = self.class.ready_for_push
20
+ .where(id: id)
21
+ .update_all(
22
+ push_claim_token: claim_token,
23
+ push_claimed_at: claimed_at,
24
+ updated_at: claimed_at
25
+ )
26
+ return false unless claimed == 1
27
+
28
+ job = PushNotificationJob.perform_later(recipient_id, message: message, link: link)
29
+ enqueue_succeeded = job && (!job.respond_to?(:successfully_enqueued?) || job.successfully_enqueued?)
30
+ unless enqueue_succeeded
31
+ enqueue_error = job.respond_to?(:enqueue_error) ? job.enqueue_error : nil
32
+ raise enqueue_error || ActiveJob::EnqueueError.new("Push notification enqueue failed")
33
+ end
34
+
35
+ acknowledged = self.class
36
+ .where(id: id, push_claim_token: claim_token, push_enqueued_at: nil)
37
+ .update_all(
38
+ push_enqueued_at: Time.current,
39
+ push_claim_token: nil,
40
+ push_claimed_at: nil,
41
+ updated_at: Time.current
42
+ )
43
+ raise ActiveRecord::StaleObjectError.new(self, "enqueue push") unless acknowledged == 1
44
+
45
+ true
46
+ rescue StandardError
47
+ release_claim(claim_token)
48
+ raise
49
+ end
50
+
51
+ private
52
+
53
+ def release_claim(claim_token)
54
+ self.class.where(id: id, push_claim_token: claim_token).update_all(
55
+ push_claim_token: nil,
56
+ push_claimed_at: nil,
57
+ updated_at: Time.current
58
+ )
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class CommentPresenceStore
5
+ module CacheBatches
6
+ def active_subscriptions_for_creatives(ids, user_id, subscription_keys, subscriptions)
7
+ lease_keys = subscription_lease_keys(ids, user_id, subscription_keys, subscriptions)
8
+ leases = Rails.cache.read_multi(*lease_keys.values)
9
+ ids.to_h do |creative_id|
10
+ subscription_ids = Array(subscriptions[subscription_keys.fetch(creative_id)])
11
+ active_ids = subscription_ids.select { |id| leases[lease_keys.fetch([ creative_id, id ])] }
12
+ [ creative_id, active_ids ]
13
+ end
14
+ end
15
+
16
+ def subscription_lease_keys(ids, user_id, subscription_keys, subscriptions)
17
+ ids.flat_map do |creative_id|
18
+ Array(subscriptions[subscription_keys.fetch(creative_id)]).map do |subscription_id|
19
+ [ [ creative_id, subscription_id ], subscription_lease_key(creative_id, user_id, subscription_id) ]
20
+ end
21
+ end.to_h
22
+ end
23
+
24
+ def viewing_topics_from_subscriptions(subscriptions_by_creative_id, user_id)
25
+ topic_keys = subscriptions_by_creative_id.to_h do |creative_id, subscription_ids|
26
+ [ creative_id, subscription_ids.map { |id| topic_key(creative_id, user_id, id) } ]
27
+ end
28
+ cached_topics = Rails.cache.read_multi(*topic_keys.values.flatten)
29
+ topic_keys.transform_values { |keys| keys.flat_map { |key| Array(cached_topics[key]) }.compact.uniq }
30
+ end
31
+
32
+ def cached_user_ids(ids)
33
+ cached = Rails.cache.read_multi(*ids.map { |creative_id| key(creative_id) })
34
+ ids.to_h { |creative_id| [ creative_id, cached[key(creative_id)] || [] ] }
35
+ end
36
+
37
+ def cached_subscriptions_for(user_ids_by_creative_id)
38
+ keys = subscription_keys_by_pair(user_ids_by_creative_id)
39
+ Rails.cache.read_multi(*keys.values)
40
+ end
41
+
42
+ def cached_subscription_leases(user_ids_by_creative_id, subscriptions)
43
+ keys = subscription_keys_by_pair(user_ids_by_creative_id)
44
+ lease_keys = keys.flat_map do |(creative_id, user_id), subscription_key|
45
+ Array(subscriptions[subscription_key]).map { |id| subscription_lease_key(creative_id, user_id, id) }
46
+ end
47
+ Rails.cache.read_multi(*lease_keys)
48
+ end
49
+
50
+ def present_users_from_cache(user_ids_by_creative_id, subscriptions, leases)
51
+ user_ids_by_creative_id.to_h do |creative_id, user_ids|
52
+ users = user_ids.select { |user_id| active_subscription_ids_from_cache(creative_id, user_id, subscriptions, leases).any? }
53
+ [ creative_id, users ]
54
+ end
55
+ end
56
+
57
+ def subscription_keys_by_pair(user_ids_by_creative_id)
58
+ user_ids_by_creative_id.flat_map do |creative_id, user_ids|
59
+ user_ids.map { |user_id| [ [ creative_id, user_id ], subscriptions_key(creative_id, user_id) ] }
60
+ end.to_h
61
+ end
62
+ end
63
+ end
64
+ end