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
@@ -39,7 +39,9 @@ module Collavre
39
39
 
40
40
  unless agent
41
41
  error_msg = I18n.t("collavre.comments.compress_command.no_agent")
42
- creative.comments.create!(user: user, topic_id: topic_id, content: "⚠️ #{error_msg}", skip_dispatch: true)
42
+ Comments::TopicMutation.call(topic_id, creative_id) do
43
+ creative.comments.create!(user: user, topic_id: topic_id, content: "⚠️ #{error_msg}", skip_dispatch: true)
44
+ end
43
45
  Rails.logger.error("[CompressJob] No AI agent found for creative #{creative_id}, topic #{topic_id}")
44
46
  return
45
47
  end
@@ -74,26 +76,28 @@ module Collavre
74
76
  title = I18n.t("collavre.comments.compress_command.summary_title", topic: topic_name)
75
77
  summary_content = "**#{title}**\n\n#{summary}"
76
78
 
77
- # Store comment IDs to delete before creating the new one (all originals including /compress command)
78
- comment_ids_to_delete = all_comments.pluck(:id)
79
+ Comments::TopicMutation.call(topic_id, creative_id) do
80
+ persist_summary(all_comments, summary_content, agent, user, compress_pattern)
81
+ end
82
+ rescue ActiveRecord::RecordNotFound => e
83
+ Rails.logger.error("[CompressJob] Record not found: #{e.message}")
84
+ end
79
85
 
80
- # Create the summary comment in the same topic.
81
- # Author it as the AI agent (not the human who ran /compress) so the
82
- # comment is recognized as AI-generated content: this makes ai_user? true,
83
- # which is what gates the "Review" button in the comment view. The summary
84
- # body is AI output, so the agent is the correct author.
86
+ private
87
+
88
+ def persist_summary(comments, summary_content, agent, user, compress_pattern)
89
+ comments = lock_matching_comments(comments)
90
+ return unless comments
91
+
92
+ creative = comments.first.creative
93
+ topic_id = comments.first.topic_id
94
+ # Author AI output as the agent so the comment is recognized as AI-generated
95
+ # content and offers the Review action.
85
96
  summary_comment = creative.comments.create!(
86
- user: agent,
87
- topic_id: topic_id,
88
- content: summary_content,
89
- skip_dispatch: true # system-generated summary, not user input
97
+ user: agent, topic_id: topic_id, content: summary_content, skip_dispatch: true
90
98
  )
91
-
92
- # Save snapshot for recovery before deleting originals
93
- # Exclude the last comment only if it's the /compress command trigger
94
- last_comment = all_comments.last
95
- last_is_command = last_comment&.content.to_s.strip.match?(compress_pattern)
96
- restorable_comments = last_is_command ? all_comments[0..-2] : all_comments.to_a
99
+ last_is_command = comments.last&.content.to_s.strip.match?(compress_pattern)
100
+ restorable_comments = last_is_command ? comments[0..-2] : comments.to_a
97
101
  CommentSnapshot.create!(
98
102
  creative: creative,
99
103
  topic_id: topic_id,
@@ -102,11 +106,18 @@ module Collavre
102
106
  comments_data: serialize_comments(restorable_comments),
103
107
  result_comment: summary_comment
104
108
  )
109
+ creative.comments.where(id: comments.map(&:id)).destroy_all
110
+ end
105
111
 
106
- # Delete original comments (excluding the newly created summary)
107
- creative.comments.where(id: comment_ids_to_delete).destroy_all
108
- rescue ActiveRecord::RecordNotFound => e
109
- Rails.logger.error("[CompressJob] Record not found: #{e.message}")
112
+ def lock_matching_comments(comments)
113
+ comment_ids = comments.map(&:id)
114
+ locked = Comment.where(id: comment_ids).order(:id).lock.index_by(&:id)
115
+ expected = comments.first
116
+ return unless locked.size == comment_ids.size && locked.values.all? do |comment|
117
+ comment.creative_id == expected.creative_id && comment.topic_id == expected.topic_id
118
+ end
119
+
120
+ comment_ids.map { |id| locked.fetch(id) }
110
121
  end
111
122
  end
112
123
  end
@@ -57,7 +57,7 @@ module Collavre
57
57
 
58
58
  # Batch-load all linked creatives for remapping (avoids N+1)
59
59
  origin_ids_to_remap = [ data[:parent_id], data[:after_id], data[:previous_sibling_id] ].compact
60
- ancestor_origin_ids = (data[:ancestors] || []).map { |a| a[:id] }.compact
60
+ ancestor_origin_ids = (data[:ancestors] || []).filter_map { |ancestor| ancestor[:origin_id] || ancestor[:id] }
61
61
  all_origin_ids = (origin_ids_to_remap + ancestor_origin_ids).uniq
62
62
 
63
63
  linked_by_user = {}
@@ -67,9 +67,12 @@ module Collavre
67
67
  linked_by_user[linked.user_id][linked.origin_id] = linked.id
68
68
  end
69
69
  end
70
+ ancestor_progress_controls = build_ancestor_progress_controls(
71
+ data[:ancestors], target_users, linked_by_user
72
+ )
70
73
 
71
74
  target_users.each do |target_user|
72
- user_data = data.dup
75
+ user_data = data.deep_dup
73
76
  user_map = linked_by_user[target_user.id] || {}
74
77
 
75
78
  # Add linked_id
@@ -86,16 +89,18 @@ module Collavre
86
89
  # Remap ancestor IDs
87
90
  if user_data[:ancestors].present?
88
91
  user_data[:ancestors] = user_data[:ancestors].map do |anc|
89
- mapped_id = user_map[anc[:id]]
90
- mapped_id ? anc.merge(id: mapped_id, origin_id: anc[:id]) : anc
92
+ origin_id = anc[:origin_id] || anc[:id]
93
+ mapped_id = user_map[origin_id]
94
+ mapped_id ? anc.merge(id: mapped_id, origin_id: origin_id) : anc
91
95
  end
92
96
  end
93
97
 
94
98
  # Per-user can_write permission
95
- user_data[:can_write] = creative.has_permission?(target_user, :write)
99
+ user_data[:can_write] = creative.has_permission?(target_user, :write) && !creative.effective_origin.read_only_source?
96
100
 
97
101
  # Per-user progress text
98
102
  creative.send(:add_progress_text!, user_data, target_user)
103
+ add_ancestor_progress_controls!(user_data[:ancestors], target_user, ancestor_progress_controls)
99
104
 
100
105
  # For created action, render per-user progress_html (includes chat button,
101
106
  # badge with turbo-cable-stream-source subscription, and progress span).
@@ -105,6 +110,10 @@ module Collavre
105
110
  # target_user already passed find_broadcast_users permission check,
106
111
  # so we can skip the expensive permission_via_ancestors walk.
107
112
  user_data[:templates][:progress_html] = render_progress_html(creative, target_user, skip_permission_check: true)
113
+ elsif action == "updated"
114
+ # Keep unrelated row-end markup (such as comment badges) intact while
115
+ # replacing the progress control with the recipient-specific version.
116
+ user_data[:progress_control_html] = render_progress_control_html(creative, target_user)
108
117
  end
109
118
 
110
119
  json_payload = { action: action.to_s, creative: user_data }.to_json
@@ -128,7 +137,7 @@ module Collavre
128
137
  linked_map = (options[:destroy_linked_map] || {}).transform_keys(&:to_i)
129
138
 
130
139
  # Batch-load linked creatives for ancestor remapping
131
- ancestor_origin_ids = (payload[:ancestors] || []).map { |a| a[:id] }.compact
140
+ ancestor_origin_ids = (payload[:ancestors] || []).filter_map { |ancestor| ancestor[:origin_id] || ancestor[:id] }
132
141
  all_origin_ids = [ payload[:parent_id], *ancestor_origin_ids ].compact.uniq
133
142
 
134
143
  linked_by_user = {}
@@ -138,9 +147,12 @@ module Collavre
138
147
  linked_by_user[linked.user_id][linked.origin_id] = linked.id
139
148
  end
140
149
  end
150
+ ancestor_progress_controls = build_ancestor_progress_controls(
151
+ payload[:ancestors], users, linked_by_user
152
+ )
141
153
 
142
154
  users.each do |target_user|
143
- user_data = payload.dup
155
+ user_data = payload.deep_dup
144
156
  user_map = linked_by_user[target_user.id] || {}
145
157
 
146
158
  user_data[:linked_id] = linked_map[target_user.id] if linked_map[target_user.id]
@@ -148,10 +160,12 @@ module Collavre
148
160
 
149
161
  if user_data[:ancestors].present?
150
162
  user_data[:ancestors] = user_data[:ancestors].map do |anc|
151
- mapped_id = user_map[anc[:id]]
152
- mapped_id ? anc.merge(id: mapped_id, origin_id: anc[:id]) : anc
163
+ origin_id = anc[:origin_id] || anc[:id]
164
+ mapped_id = user_map[origin_id]
165
+ mapped_id ? anc.merge(id: mapped_id, origin_id: origin_id) : anc
153
166
  end
154
167
  end
168
+ add_ancestor_progress_controls!(user_data[:ancestors], target_user, ancestor_progress_controls)
155
169
 
156
170
  json_payload = { action: "destroyed", creative: user_data }.to_json
157
171
  Turbo::StreamsChannel.broadcast_action_to(
@@ -169,51 +183,150 @@ module Collavre
169
183
  # Includes:
170
184
  # - turbo-cable-stream-source (per-user subscription for badge updates)
171
185
  # - comment button (hidden via no-comments class — 0 comments initially)
172
- # - progress span
186
+ # - the same progress control used by normal server rendering
173
187
  def render_progress_html(creative, user, skip_permission_check: false)
174
- origin = creative.effective_origin
175
- progress = creative.progress || 0
176
- pct = (progress * 100).round
177
-
178
- # Progress span (comes first, matching helper output order)
179
- css_class = pct >= 100 ? "creative-progress-complete" : "creative-progress-incomplete"
180
- completion_mark = Collavre::SystemSetting.completion_mark
181
- display_text = pct >= 100 && !completion_mark.nil? ? completion_mark : "#{pct}%"
182
- progress_span = %(<span class="#{css_class}">#{display_text}</span>)
183
-
184
- # Comment part — only render if user has feedback permission (matching helper behavior)
185
- # When skip_permission_check is true, the user was already verified by find_broadcast_users
186
- # (which checks :read permission on target + ancestors). Feedback permission is a subset
187
- # of read permission in practice, so we can safely render the comment button.
188
- comment_part = ""
189
- has_feedback = skip_permission_check ||
190
- creative.has_permission?(user, :feedback) ||
191
- permission_via_ancestors(creative, user, :feedback)
192
- if has_feedback
193
- # Generate signed stream name for turbo-cable-stream-source
194
- stream_name = Turbo::StreamsChannel.signed_stream_name([ user, origin, :comment_badge ])
195
- stream_tag = %(<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="#{stream_name}"></turbo-cable-stream-source>)
196
-
197
- # Badge (no comments yet — hidden)
198
- badge_id = "comment-badge-#{origin.id}"
199
- badge = %(<span id="#{badge_id}" class="badge" style="display:none" data-count="0" data-controller="comment-badge" data-comment-badge-has-comments-value="false"></span>)
200
-
201
- # Comment button — no-comments hides via visibility:hidden (0 comments initially)
202
- comment_icon = read_svg("comment.svg", class: "comment-icon")
203
- btn_classes = "comments-btn creative-action-btn no-comments"
204
- comment_btn = %(<button name="show-comments-btn" class="#{btn_classes}" data-creative-id="#{creative.id}" data-can-comment="true" data-creative-snippet="#{ERB::Util.html_escape(creative.creative_snippet)}">)
205
- comment_btn += %(#{comment_icon}#{badge}</button>)
206
-
207
- comment_part = "#{stream_tag}#{comment_btn}"
208
- end
209
-
210
- # Wrap in creative-row-end div — order: progress, then comment (matching helper)
211
- %(<div class="creative-row-end">#{progress_span}#{comment_part}</div>)
188
+ I18n.with_locale(user.locale.presence || I18n.default_locale) do
189
+ origin = creative.effective_origin
190
+ progress_part = render_progress_control_html(creative, user)
191
+
192
+ # Comment part — only render if user has feedback permission (matching helper behavior)
193
+ # When skip_permission_check is true, the user was already verified by find_broadcast_users
194
+ # (which checks :read permission on target + ancestors). Feedback permission is a subset
195
+ # of read permission in practice, so we can safely render the comment button.
196
+ comment_part = ""
197
+ has_feedback = skip_permission_check ||
198
+ creative.has_permission?(user, :feedback) ||
199
+ permission_via_ancestors(creative, user, :feedback)
200
+ if has_feedback
201
+ # Generate signed stream name for turbo-cable-stream-source
202
+ stream_name = Turbo::StreamsChannel.signed_stream_name([ user, origin, :comment_badge ])
203
+ stream_tag = %(<turbo-cable-stream-source channel="Turbo::StreamsChannel" signed-stream-name="#{stream_name}"></turbo-cable-stream-source>)
204
+
205
+ # Badge (no comments yet — hidden)
206
+ badge_id = "comment-badge-#{origin.id}"
207
+ badge = %(<span id="#{badge_id}" class="badge" style="display:none" data-count="0" data-controller="comment-badge" data-comment-badge-has-comments-value="false"></span>)
208
+
209
+ # Comment button — no-comments hides via visibility:hidden (0 comments initially)
210
+ comment_icon = read_svg("comment.svg", class: "comment-icon")
211
+ btn_classes = "comments-btn creative-action-btn no-comments"
212
+ comment_btn = %(<button name="show-comments-btn" class="#{btn_classes}" data-creative-id="#{creative.id}" data-can-comment="true" data-creative-snippet="#{ERB::Util.html_escape(creative.creative_snippet)}">)
213
+ comment_btn += %(#{comment_icon}#{badge}</button>)
214
+
215
+ comment_part = "#{stream_tag}#{comment_btn}"
216
+ end
217
+
218
+ # Wrap in creative-row-end div — order: progress, then comment (matching helper)
219
+ %(<div class="creative-row-end">#{progress_part}#{comment_part}</div>)
220
+ end
212
221
  rescue StandardError => e
213
222
  Rails.logger.warn "[CreativeBroadcastJob] render_progress_html failed for creative##{creative.id} user##{user.id}: #{e.message}"
214
223
  nil
215
224
  end
216
225
 
226
+ def add_ancestor_progress_controls!(ancestors, user, controls_by_user)
227
+ ancestors&.each do |ancestor|
228
+ origin_id = ancestor[:origin_id] || ancestor[:id]
229
+ control = controls_by_user.dig(user.id, origin_id)
230
+ next unless control
231
+
232
+ ancestor.merge!(control)
233
+ end
234
+ end
235
+
236
+ # Build every recipient's ancestor controls in a bounded number of queries.
237
+ # The row shown to a recipient may be a linked shell, while its child state
238
+ # and permission resolve through the effective origin.
239
+ def build_ancestor_progress_controls(ancestors, users, linked_by_user)
240
+ origin_ids = ancestors.to_a.filter_map { |ancestor| ancestor[:origin_id] || ancestor[:id] }.uniq
241
+ return {} if origin_ids.empty?
242
+
243
+ effective_ids_by_origin = Creatives::EffectiveCreativeResolution.effective_creative_ids(origin_ids)
244
+ effective_ids = effective_ids_by_origin.values.uniq
245
+ displayed_ids = linked_by_user.values.flat_map(&:values)
246
+ creatives_by_id = Creative.where(id: (origin_ids + effective_ids + displayed_ids).uniq).index_by(&:id)
247
+ effective_creatives = effective_ids.index_with { |id| creatives_by_id[id] }.compact
248
+ has_visible_children_by_user = visible_children_by_user(effective_creatives, users)
249
+ writable_by_user = batch_write_permissions(effective_creatives, users)
250
+
251
+ users.each_with_object({}) do |user, controls_by_user|
252
+ user_map = linked_by_user[user.id] || {}
253
+ controls_by_user[user.id] = origin_ids.each_with_object({}) do |origin_id, controls|
254
+ effective = effective_creatives[effective_ids_by_origin[origin_id]]
255
+ displayed = creatives_by_id[user_map[origin_id] || origin_id]
256
+ next unless effective && displayed
257
+
258
+ progress = effective.progress || 0
259
+ controls[origin_id] = {
260
+ progress: progress,
261
+ progress_text: effective.send(:format_progress_text, progress, user),
262
+ progress_html: render_progress_control_html(
263
+ displayed,
264
+ user,
265
+ effective: effective,
266
+ progress: progress,
267
+ has_children: has_visible_children_by_user.fetch(user.id, Set.new).include?(effective.id),
268
+ can_write: writable_by_user.dig(user.id, effective.id) && !effective.read_only_source?
269
+ )
270
+ }
271
+ end
272
+ end
273
+ end
274
+
275
+ # Match ChildrenIndex's default tree semantics: archived children and
276
+ # children the recipient cannot read do not make a visible row a rollup.
277
+ # Candidate children are loaded once, then visibility is resolved in a
278
+ # bounded batch per recipient rather than once per ancestor.
279
+ def visible_children_by_user(effective_creatives, users)
280
+ child_rows = Creative.where(parent_id: effective_creatives.keys, archived_at: nil).pluck(:id, :parent_id)
281
+ child_ids = child_rows.map(&:first)
282
+
283
+ users.each_with_object({}) do |user, result|
284
+ readable_ids = Creatives::PermissionFilter.new(user: user).readable_ids(child_ids).to_set
285
+ result[user.id] = child_rows.each_with_object(Set.new) do |(child_id, parent_id), parent_ids|
286
+ parent_ids << parent_id if readable_ids.include?(child_id)
287
+ end
288
+ end
289
+ end
290
+
291
+ def batch_write_permissions(effective_creatives, users)
292
+ effective_ids = effective_creatives.keys
293
+ return {} if effective_ids.empty?
294
+
295
+ user_ids = users.map(&:id)
296
+ permission_rows = CreativeSharesCache.where(creative_id: effective_ids, user_id: [ nil, *user_ids ])
297
+ .pluck(:creative_id, :user_id, :permission)
298
+ public_permissions = {}
299
+ user_permissions = Hash.new { |hash, key| hash[key] = {} }
300
+ permission_rows.each do |creative_id, user_id, permission|
301
+ if user_id.nil?
302
+ public_permissions[creative_id] = permission
303
+ else
304
+ user_permissions[user_id][creative_id] = permission
305
+ end
306
+ end
307
+
308
+ users.each_with_object({}) do |user, permissions_by_user|
309
+ permissions_by_user[user.id] = effective_ids.index_with do |creative_id|
310
+ effective = effective_creatives.fetch(creative_id)
311
+ permission = user_permissions[user.id].fetch(creative_id, public_permissions[creative_id])
312
+ permission_rank = CreativeShare.permissions.fetch(permission.to_s, 0)
313
+ effective.user_id == user.id || permission_rank >= CreativeShare.permissions[:write]
314
+ end
315
+ end
316
+ end
317
+
318
+ def render_progress_control_html(creative, user, effective: nil, progress: nil, has_children: nil, can_write: nil)
319
+ I18n.with_locale(user.locale.presence || I18n.default_locale) do
320
+ effective ||= creative.effective_origin
321
+ Collavre::ApplicationController.helpers.render_progress_control(
322
+ creative,
323
+ progress || effective.progress || 0,
324
+ has_children: has_children.nil? ? effective.children.exists? : has_children,
325
+ can_write: (can_write.nil? ? creative.has_permission?(user, :write) : can_write) && !effective.read_only_source?
326
+ )
327
+ end
328
+ end
329
+
217
330
  # Check permission via ancestor chain (fallback when creative_shares_caches is not yet populated)
218
331
  def permission_via_ancestors(creative, user, permission)
219
332
  current = creative.parent
@@ -11,7 +11,7 @@ module Collavre
11
11
  queue_as :default
12
12
 
13
13
  def perform(creative_id:, topic_id:, agent_id:, message:)
14
- creative = Creative.find_by(id: creative_id)
14
+ creative = Creative.find_by(id: creative_id)&.effective_origin
15
15
  agent = User.find_by(id: agent_id)
16
16
 
17
17
  unless creative && agent
@@ -21,26 +21,26 @@ module Collavre
21
21
  return
22
22
  end
23
23
 
24
- # topic_id nil means main topic; otherwise look up the topic
25
- topic = nil
26
- if topic_id.present?
27
- topic = Topic.find_by(id: topic_id)
28
- unless topic
29
- Rails.logger.warn("[CronActionJob] Skipping: topic=#{topic_id} not found")
30
- return
31
- end
24
+ topic = topic_id.present? ? Topic.find_by(id: topic_id) : creative.main_topic(fallback_user: agent)
25
+ unless topic
26
+ Rails.logger.warn("[CronActionJob] Skipping: topic=#{topic_id} not found")
27
+ return
32
28
  end
33
29
 
34
- comment = creative.comments.create!(
35
- content: message,
36
- user: agent,
37
- topic_id: topic&.id,
38
- private: false,
39
- skip_dispatch: true # manual dispatch below (AI user would skip model callback)
40
- )
30
+ comment = create_comment(creative, topic, agent, message)
31
+ return unless comment
41
32
 
42
33
  # Dispatch system event to trigger agent orchestration pipeline
43
34
  # (manual because cron-initiated AI messages intentionally need dispatch)
35
+ #
36
+ # The topic comes from the persisted row, not from `topic` above. A cron
37
+ # scheduled against Main passes `topic_id: nil`, but Comment#assign_main_topic
38
+ # files the comment under the creative's real Main topic — so nil describes
39
+ # the *argument*, never where the comment ended up. Everything downstream
40
+ # believes this payload: the task's topic_id, the slot it is admitted into,
41
+ # and the scope AgentOrchestrator.refresh_deferred_context! re-selects an
42
+ # anchor from on promotion. A payload naming a topic its own comment is not
43
+ # in makes all three answer for an empty topic.
44
44
  SystemEvents::Dispatcher.dispatch("comment_created", {
45
45
  comment: {
46
46
  id: comment.id,
@@ -52,16 +52,34 @@ module Collavre
52
52
  description: creative.description
53
53
  },
54
54
  topic: {
55
- id: topic&.id
55
+ id: comment.topic_id
56
56
  },
57
57
  chat: {
58
58
  content: comment.content
59
59
  }
60
- })
60
+ }, source: "cron")
61
61
 
62
62
  Rails.logger.info(
63
63
  "[CronActionJob] Posted cron message to creative #{creative_id}, topic #{topic_id || 'main'}"
64
64
  )
65
65
  end
66
+
67
+ private
68
+
69
+ def create_comment(creative, topic, agent, message)
70
+ comment = nil
71
+ applied = Comments::TopicMutation.call(topic.id, creative.id) do
72
+ comment = creative.comments.create!(
73
+ content: message, user: agent, topic: topic, private: false,
74
+ skip_dispatch: true
75
+ )
76
+ end
77
+ return comment if applied
78
+
79
+ Rails.logger.warn(
80
+ "[CronActionJob] Skipping: topic=#{topic.id} no longer belongs to creative=#{creative.id}"
81
+ )
82
+ nil
83
+ end
66
84
  end
67
85
  end
@@ -35,13 +35,8 @@ module Collavre
35
35
  end
36
36
 
37
37
  topic = find_or_create_trigger_topic(child, agent)
38
-
39
- # Initialize trigger loop state on the child creative
40
- initialize_trigger_loop(child, topic)
41
-
42
- # Step 1: Find existing or create new trigger comment (idempotent)
43
- comment = find_trigger_comment(child, parent, topic) ||
44
- create_trigger_comment(child, parent, agent, topic)
38
+ comment = prepare_trigger(child, parent, agent, topic)
39
+ return unless comment
45
40
 
46
41
  # Step 2: Skip if dispatch already produced a Task for this comment
47
42
  return if task_exists_for?(comment)
@@ -58,6 +53,19 @@ module Collavre
58
53
 
59
54
  private
60
55
 
56
+ def prepare_trigger(child, parent, agent, topic)
57
+ comment = nil
58
+ applied = Comments::TopicMutation.call(topic.id, child.id) do
59
+ # The loop reference and its trigger comment must be created under the
60
+ # topic lock. A concurrent topic move either sees the reference and is
61
+ # rejected, or wins first and makes this stale job a no-op.
62
+ initialize_trigger_loop(child.reload, topic)
63
+ comment = find_trigger_comment(child, parent, topic) ||
64
+ create_trigger_comment(child, parent, agent, topic)
65
+ end
66
+ comment if applied
67
+ end
68
+
61
69
  def post_trigger_failure_notice(child, parent)
62
70
  topic = child.topics.find_by(name: DROP_TRIGGER_TOPIC_NAME) ||
63
71
  create_drop_trigger_topic(child)
@@ -102,6 +110,7 @@ module Collavre
102
110
  # selection cap so full Main history transfers regardless of length.
103
111
  main_comment_ids = main.comments
104
112
  .visible_to(creative.user)
113
+ .without_approval_action
105
114
  .order(:created_at)
106
115
  .pluck(:id)
107
116
 
@@ -113,7 +122,12 @@ module Collavre
113
122
  user: creative.user,
114
123
  source_topic: main,
115
124
  name: DROP_TRIGGER_TOPIC_NAME
116
- ).call(comment_ids: main_comment_ids, enforce_limit: false, auto_select: false)
125
+ ).call(
126
+ comment_ids: main_comment_ids,
127
+ enforce_limit: false,
128
+ auto_select: false,
129
+ skip_approval_actions: true
130
+ )
117
131
  else
118
132
  creative.topics.create!(
119
133
  name: DROP_TRIGGER_TOPIC_NAME,
@@ -190,7 +204,7 @@ module Collavre
190
204
  # Use Comment#dispatch_payload — single source of truth shared with
191
205
  # the after_create_commit callback, preventing payload drift.
192
206
  scheduled_agents = SystemEvents::Dispatcher.dispatch(
193
- "comment_created", comment.dispatch_payload
207
+ "comment_created", comment.dispatch_payload, source: "drop_trigger"
194
208
  )
195
209
 
196
210
  if scheduled_agents.blank?
@@ -24,7 +24,7 @@ module Collavre
24
24
  .includes(:user, images_attachments: :blob)
25
25
  .to_a
26
26
 
27
- return if comments.size < 2
27
+ return unless mergeable_selection?(comments)
28
28
 
29
29
  target_comment = comments.first
30
30
  topic_id = target_comment.topic_id
@@ -68,24 +68,41 @@ module Collavre
68
68
  return
69
69
  end
70
70
 
71
- # Update the first comment and delete the rest atomically
72
- remaining_ids = comments[1..].map(&:id)
73
- ActiveRecord::Base.transaction do
74
- # Save snapshot for recovery before modifying/deleting originals
75
- CommentSnapshot.create!(
76
- creative: creative,
77
- topic_id: topic_id,
78
- user_id: user_id,
79
- operation: "merge",
80
- comments_data: serialize_comments(comments),
81
- result_comment: target_comment
82
- )
83
-
84
- target_comment.update!(content: merged_content)
85
- creative.comments.where(id: remaining_ids).destroy_all
71
+ Comments::TopicMutation.call(topic_id, creative_id) do
72
+ current_comments = lock_current_comments(comments, creative_id, topic_id)
73
+ persist_merge(current_comments, merged_content, user_id) if current_comments
86
74
  end
87
75
  rescue ActiveRecord::RecordNotFound => e
88
76
  Rails.logger.error("[MergeCommentsJob] Record not found: #{e.message}")
89
77
  end
78
+
79
+ private
80
+
81
+ def mergeable_selection?(comments)
82
+ comments.size >= 2 && comments.map(&:topic_id).uniq.size == 1
83
+ end
84
+
85
+ def lock_current_comments(comments, creative_id, topic_id)
86
+ current_comments = Comment.where(id: comments.map(&:id)).order(created_at: :asc).lock.to_a
87
+ return unless current_comments.size == comments.size
88
+ return unless current_comments.all? { |comment| comment.creative_id == creative_id && comment.topic_id == topic_id }
89
+
90
+ current_comments
91
+ end
92
+
93
+ def persist_merge(comments, merged_content, user_id)
94
+ target_comment = comments.first
95
+ creative = target_comment.creative
96
+ CommentSnapshot.create!(
97
+ creative: creative,
98
+ topic_id: target_comment.topic_id,
99
+ user_id: user_id,
100
+ operation: "merge",
101
+ comments_data: serialize_comments(comments),
102
+ result_comment: target_comment
103
+ )
104
+ target_comment.update!(content: merged_content)
105
+ creative.comments.where(id: comments[1..].map(&:id)).destroy_all
106
+ end
90
107
  end
91
108
  end