collavre 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (440) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +0 -1
  3. data/app/assets/stylesheets/collavre/comments_popup.css +165 -99
  4. data/app/assets/stylesheets/collavre/creatives.css +346 -37
  5. data/app/assets/stylesheets/collavre/desktop_setup.css +246 -0
  6. data/app/assets/stylesheets/collavre/gnb.css +64 -3
  7. data/app/assets/stylesheets/collavre/landing.css +108 -3
  8. data/app/assets/stylesheets/collavre/mention_menu.css +32 -0
  9. data/app/assets/stylesheets/collavre/popup.css +14 -0
  10. data/app/assets/stylesheets/collavre/print.css +0 -1
  11. data/app/assets/stylesheets/collavre/search_popup.css +1 -1
  12. data/app/assets/stylesheets/collavre/secret_fields.css +3 -0
  13. data/app/assets/stylesheets/collavre/workspace.css +307 -0
  14. data/app/channels/collavre/agent_channel.rb +14 -21
  15. data/app/channels/collavre/comments_presence_channel.rb +181 -21
  16. data/app/channels/collavre/inbox_badge_channel.rb +7 -1
  17. data/app/components/collavre/inbox/badge_component.rb +6 -13
  18. data/app/components/collavre/progress_filter_component.html.erb +2 -2
  19. data/app/components/collavre/progress_filter_component.rb +20 -0
  20. data/app/controllers/collavre/admin/settings_controller.rb +4 -1
  21. data/app/controllers/collavre/agent_connections_controller.rb +165 -0
  22. data/app/controllers/collavre/agent_gateways_controller.rb +132 -0
  23. data/app/controllers/collavre/agent_provisioning_controller.rb +79 -0
  24. data/app/controllers/collavre/api/v1/agents_controller.rb +45 -97
  25. data/app/controllers/collavre/application_controller.rb +13 -2
  26. data/app/controllers/collavre/comment_read_pointers_controller.rb +69 -40
  27. data/app/controllers/collavre/comments_controller.rb +45 -131
  28. data/app/controllers/collavre/concerns/shareable.rb +9 -3
  29. data/app/controllers/collavre/creatives_controller.rb +144 -11
  30. data/app/controllers/collavre/desktop_gateway_registrations_controller.rb +173 -0
  31. data/app/controllers/collavre/desktop_setup_controller.rb +119 -0
  32. data/app/controllers/collavre/desktop_sidecar_health_controller.rb +35 -0
  33. data/app/controllers/collavre/features_controller.rb +57 -0
  34. data/app/controllers/collavre/invites_controller.rb +12 -1
  35. data/app/controllers/collavre/llm_models_controller.rb +8 -0
  36. data/app/controllers/collavre/notices_controller.rb +20 -0
  37. data/app/controllers/collavre/tasks_controller.rb +40 -6
  38. data/app/controllers/collavre/topics_controller.rb +130 -53
  39. data/app/controllers/collavre/user_creative_preferences_controller.rb +214 -30
  40. data/app/controllers/concerns/collavre/comments/batch_operations.rb +15 -3
  41. data/app/controllers/concerns/collavre/comments/conversion.rb +1 -1
  42. data/app/controllers/concerns/collavre/users_controller/ai_user_management.rb +118 -8
  43. data/app/controllers/concerns/collavre/users_controller/profile_and_settings.rb +1 -15
  44. data/app/controllers/concerns/collavre/users_controller/registration.rb +2 -2
  45. data/app/errors/collavre/turn_deadline_error.rb +20 -0
  46. data/app/helpers/collavre/application_helper.rb +38 -0
  47. data/app/helpers/collavre/creatives_helper.rb +56 -22
  48. data/app/helpers/collavre/navigation_helper.rb +12 -0
  49. data/app/javascript/collavre.js +1 -2
  50. data/app/javascript/components/InlineLexicalEditor.jsx +30 -21
  51. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +32 -2
  52. data/app/javascript/components/__tests__/creative_tree_row_progress_toggle.test.js +185 -0
  53. data/app/javascript/components/creative_tree_row.js +62 -22
  54. data/app/javascript/controllers/__tests__/agent_connection_controller.test.js +195 -0
  55. data/app/javascript/controllers/__tests__/desktop_proxy_setup_controller.test.js +96 -0
  56. data/app/javascript/controllers/__tests__/index.test.js +42 -0
  57. data/app/javascript/controllers/__tests__/last_visited_creative_controller.test.js +103 -0
  58. data/app/javascript/controllers/__tests__/link_creative_controller.test.js +253 -2
  59. data/app/javascript/controllers/__tests__/progress_filter_controller.test.js +134 -0
  60. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +310 -0
  61. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +111 -0
  62. data/app/javascript/controllers/__tests__/workspace_tree_controller.test.js +1011 -0
  63. data/app/javascript/controllers/agent_connection_controller.js +357 -0
  64. data/app/javascript/controllers/agent_vendor_controller.js +23 -0
  65. data/app/javascript/controllers/comment_controller.js +3 -1
  66. data/app/javascript/controllers/comments/__tests__/feature_cards_controller.test.js +223 -0
  67. data/app/javascript/controllers/comments/__tests__/form_controller_default_placeholder.test.js +83 -0
  68. data/app/javascript/controllers/comments/__tests__/form_controller_draft.test.js +2069 -0
  69. data/app/javascript/controllers/comments/__tests__/form_controller_ime_send.test.js +324 -0
  70. data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +28 -2
  71. data/app/javascript/controllers/comments/__tests__/form_controller_search_submit.test.js +106 -0
  72. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft.test.js +134 -0
  73. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_creative_switch.test.js +166 -0
  74. data/app/javascript/controllers/comments/__tests__/form_controller_stashed_draft_failure.test.js +183 -0
  75. data/app/javascript/controllers/comments/__tests__/list_controller_deep_link_topic.test.js +467 -0
  76. data/app/javascript/controllers/comments/__tests__/list_controller_docked.test.js +115 -0
  77. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +245 -0
  78. data/app/javascript/controllers/comments/__tests__/list_controller_read_pointer.test.js +276 -0
  79. data/app/javascript/controllers/comments/__tests__/list_controller_stream_archived.test.js +221 -0
  80. data/app/javascript/controllers/comments/__tests__/list_controller_stream_search.test.js +104 -0
  81. data/app/javascript/controllers/comments/__tests__/placeholder_controller.test.js +79 -0
  82. data/app/javascript/controllers/comments/__tests__/popup_controller.test.js +754 -1
  83. data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +717 -15
  84. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  85. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +184 -0
  86. data/app/javascript/controllers/comments/__tests__/topics_controller_archived.test.js +879 -0
  87. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  88. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_scroll.test.js +92 -0
  89. data/app/javascript/controllers/comments/__tests__/topics_controller_delete.test.js +130 -0
  90. data/app/javascript/controllers/comments/__tests__/topics_controller_docked.test.js +32 -0
  91. data/app/javascript/controllers/comments/__tests__/topics_controller_last_topic_echo.test.js +1938 -0
  92. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +256 -0
  93. data/app/javascript/controllers/comments/__tests__/topics_controller_scroll.test.js +182 -0
  94. data/app/javascript/controllers/comments/__tests__/topics_controller_selection_race.test.js +981 -0
  95. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +313 -0
  96. data/app/javascript/controllers/comments/feature_cards_controller.js +83 -0
  97. data/app/javascript/controllers/comments/form_controller.js +795 -14
  98. data/app/javascript/controllers/comments/list_controller.js +338 -52
  99. data/app/javascript/controllers/comments/placeholder_controller.js +30 -0
  100. data/app/javascript/controllers/comments/popup_controller.js +425 -83
  101. data/app/javascript/controllers/comments/presence_controller.js +317 -55
  102. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  103. data/app/javascript/controllers/comments/topics_controller.js +1772 -100
  104. data/app/javascript/controllers/common_popup_controller.js +2 -2
  105. data/app/javascript/controllers/creatives/__tests__/select_mode_controller.test.js +139 -0
  106. data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +605 -0
  107. data/app/javascript/controllers/creatives/__tests__/write_access_request_controller.test.js +125 -0
  108. data/app/javascript/controllers/creatives/select_mode_controller.js +7 -2
  109. data/app/javascript/controllers/creatives/tree_controller.js +140 -31
  110. data/app/javascript/controllers/creatives/write_access_request_controller.js +58 -0
  111. data/app/javascript/controllers/desktop_proxy_setup_controller.js +51 -0
  112. data/app/javascript/controllers/gateway_check_controller.js +36 -0
  113. data/app/javascript/controllers/index.js +25 -1
  114. data/app/javascript/controllers/last_visited_creative_controller.js +58 -0
  115. data/app/javascript/controllers/link_creative_controller.js +89 -18
  116. data/app/javascript/controllers/progress_filter_controller.js +12 -22
  117. data/app/javascript/controllers/search_popup_controller.js +64 -46
  118. data/app/javascript/controllers/share_user_search_controller.js +2 -2
  119. data/app/javascript/controllers/topic_list_controller.js +44 -7
  120. data/app/javascript/controllers/workspace_tree_controller.js +662 -0
  121. data/app/javascript/creatives/__tests__/tree_renderer.test.js +80 -0
  122. data/app/javascript/creatives/drag_drop/__tests__/drop_completion_empty_state.test.js +98 -0
  123. data/app/javascript/creatives/drag_drop/__tests__/topic_move_released_agent.test.js +125 -0
  124. data/app/javascript/creatives/drag_drop/event_handlers.js +12 -0
  125. data/app/javascript/creatives/tree_renderer.js +65 -22
  126. data/app/javascript/lib/__tests__/chat_drafts.test.js +1458 -0
  127. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +8 -3
  128. data/app/javascript/lib/__tests__/common_popup_placement.test.js +374 -0
  129. data/app/javascript/lib/__tests__/common_popup_reshow.test.js +131 -0
  130. data/app/javascript/lib/__tests__/creative_link_prefetch.test.js +46 -0
  131. data/app/javascript/lib/__tests__/last_visited_creative.test.js +87 -0
  132. data/app/javascript/lib/__tests__/skill_html_entities.test.js +23 -0
  133. data/app/javascript/lib/__tests__/turbo_stream_actions.test.js +190 -0
  134. data/app/javascript/lib/api/__tests__/creatives.test.js +68 -0
  135. data/app/javascript/lib/api/__tests__/csrf_fetch.test.js +27 -0
  136. data/app/javascript/lib/api/__tests__/topics_save_last_topic.test.js +112 -0
  137. data/app/javascript/lib/api/creatives.js +32 -13
  138. data/app/javascript/lib/api/csrf_fetch.js +6 -4
  139. data/app/javascript/lib/api/topics.js +63 -5
  140. data/app/javascript/lib/chat_drafts.js +786 -0
  141. data/app/javascript/lib/common_popup.js +164 -25
  142. data/app/javascript/lib/creative_link_prefetch.js +27 -0
  143. data/app/javascript/lib/last_visited_creative.js +97 -0
  144. data/app/javascript/lib/lexical/__tests__/creative_link_node.test.js +162 -0
  145. data/app/javascript/lib/lexical/__tests__/creative_link_trigger.test.js +133 -0
  146. data/app/javascript/lib/lexical/__tests__/link_toolbar.test.js +62 -0
  147. data/app/javascript/lib/lexical/creative_link_node.js +101 -0
  148. data/app/javascript/lib/lexical/creative_link_trigger.js +98 -0
  149. data/app/javascript/lib/lexical/link_toolbar.js +23 -0
  150. data/app/javascript/lib/turbo_stream_actions.js +23 -16
  151. data/app/javascript/lib/utils/__tests__/filter_navigation.test.js +210 -0
  152. data/app/javascript/lib/utils/__tests__/markdown.test.js +44 -0
  153. data/app/javascript/lib/utils/filter_navigation.js +100 -0
  154. data/app/javascript/modules/__tests__/command_menu_creative_picker.test.js +62 -0
  155. data/app/javascript/modules/__tests__/command_menu_typeahead.test.js +267 -0
  156. data/app/javascript/modules/__tests__/creative_link_picker.test.js +85 -0
  157. data/app/javascript/modules/__tests__/creative_row_editor_archive.test.js +391 -0
  158. data/app/javascript/modules/__tests__/creative_row_editor_delegated_clicks.test.js +158 -0
  159. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_recovery.test.js +519 -0
  160. data/app/javascript/modules/__tests__/creative_row_editor_empty_state_removal.test.js +346 -0
  161. data/app/javascript/modules/__tests__/creative_row_editor_session.test.js +144 -0
  162. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +30 -0
  163. data/app/javascript/modules/__tests__/creative_tree_empty_state.test.js +186 -0
  164. data/app/javascript/modules/__tests__/dom_listener_registry.test.js +67 -0
  165. data/app/javascript/modules/__tests__/support/inline_editor_dom.js +159 -0
  166. data/app/javascript/modules/command_args_form.js +0 -12
  167. data/app/javascript/modules/command_menu.js +80 -38
  168. data/app/javascript/modules/creative_link_picker.js +56 -0
  169. data/app/javascript/modules/creative_row_editor.js +446 -243
  170. data/app/javascript/modules/creative_row_editor_delegated_clicks.js +87 -0
  171. data/app/javascript/modules/creative_tree_dom.js +4 -0
  172. data/app/javascript/modules/creative_tree_empty_state.js +80 -0
  173. data/app/javascript/modules/dom_listener_registry.js +23 -0
  174. data/app/javascript/modules/mention_menu.js +2 -3
  175. data/app/javascript/utils/__tests__/caret_anchor.test.js +75 -0
  176. data/app/javascript/utils/__tests__/chevron_icons.test.js +15 -0
  177. data/app/javascript/utils/caret_position.js +10 -0
  178. data/app/javascript/utils/chevron_icons.js +7 -0
  179. data/app/jobs/collavre/ai_agent_job.rb +262 -72
  180. data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +11 -36
  181. data/app/jobs/collavre/comment_badges_broadcast_job.rb +28 -0
  182. data/app/jobs/collavre/comment_link_preview_job.rb +33 -0
  183. data/app/jobs/collavre/comment_notification_job.rb +14 -0
  184. data/app/jobs/collavre/comment_push_delivery_sweep_job.rb +17 -0
  185. data/app/jobs/collavre/compress_job.rb +33 -22
  186. data/app/jobs/collavre/creative_broadcast_job.rb +161 -48
  187. data/app/jobs/collavre/cron_action_job.rb +36 -18
  188. data/app/jobs/collavre/drop_trigger_job.rb +23 -9
  189. data/app/jobs/collavre/merge_comments_job.rb +33 -16
  190. data/app/jobs/collavre/push_notification_job.rb +62 -18
  191. data/app/jobs/collavre/restore_dropped_dispatches_job.rb +17 -0
  192. data/app/jobs/collavre/stuck_detector_job.rb +1 -1
  193. data/app/jobs/collavre/touch_creative_subtree_job.rb +14 -0
  194. data/app/models/collavre/agent_gateway.rb +200 -0
  195. data/app/models/collavre/agent_subscription.rb +4 -4
  196. data/app/models/collavre/agent_workspace.rb +210 -0
  197. data/app/models/collavre/comment/broadcastable.rb +22 -52
  198. data/app/models/collavre/comment/notifiable.rb +154 -77
  199. data/app/models/collavre/comment/topic_membership.rb +41 -0
  200. data/app/models/collavre/comment.rb +336 -31
  201. data/app/models/collavre/comment_notification_delivery.rb +61 -0
  202. data/app/models/collavre/comment_presence_store/cache_batches.rb +64 -0
  203. data/app/models/collavre/comment_presence_store.rb +272 -12
  204. data/app/models/collavre/comment_read_pointer.rb +9 -15
  205. data/app/models/collavre/creative/describable.rb +8 -2
  206. data/app/models/collavre/creative/permissible.rb +11 -0
  207. data/app/models/collavre/creative.rb +29 -4
  208. data/app/models/collavre/creative_share.rb +109 -4
  209. data/app/models/collavre/current.rb +1 -0
  210. data/app/models/collavre/llm_model.rb +62 -0
  211. data/app/models/collavre/system_setting.rb +12 -1
  212. data/app/models/collavre/task.rb +121 -2
  213. data/app/models/collavre/topic.rb +51 -0
  214. data/app/models/collavre/user.rb +101 -41
  215. data/app/models/collavre/user_creative_preference.rb +3 -1
  216. data/app/services/collavre/agent_provisioning/archive.rb +108 -0
  217. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +16 -3
  218. data/app/services/collavre/ai_agent/agent_lifecycle_manager.rb +63 -6
  219. data/app/services/collavre/ai_agent/approval_handler.rb +12 -4
  220. data/app/services/collavre/ai_agent/claude_channel_adapter.rb +10 -5
  221. data/app/services/collavre/ai_agent/merged_trigger_comments.rb +164 -0
  222. data/app/services/collavre/ai_agent/message_builder.rb +103 -16
  223. data/app/services/collavre/ai_agent/session_provisioner.rb +3 -7
  224. data/app/services/collavre/ai_agent/task_claim_service.rb +29 -15
  225. data/app/services/collavre/ai_agent/task_reply_service.rb +67 -0
  226. data/app/services/collavre/ai_agent_service.rb +108 -20
  227. data/app/services/collavre/ai_client.rb +211 -21
  228. data/app/services/collavre/cli_proxy/client.rb +147 -0
  229. data/app/services/collavre/cli_proxy/endpoint_policy.rb +81 -0
  230. data/app/services/collavre/cli_proxy/identity.rb +47 -0
  231. data/app/services/collavre/cli_proxy/safe_net_http_adapter.rb +19 -0
  232. data/app/services/collavre/command_menu_service.rb +0 -10
  233. data/app/services/collavre/comment_move_service.rb +96 -7
  234. data/app/services/collavre/comment_snapshot_restore_service.rb +11 -5
  235. data/app/services/collavre/comments/action_executor.rb +48 -20
  236. data/app/services/collavre/comments/command_processor.rb +0 -1
  237. data/app/services/collavre/comments/list_response.rb +133 -0
  238. data/app/services/collavre/comments/read_pointer_writer.rb +210 -0
  239. data/app/services/collavre/comments/read_receipt_broadcaster.rb +157 -0
  240. data/app/services/collavre/comments/read_receipt_index.rb +152 -0
  241. data/app/services/collavre/comments/topic_command.rb +72 -1
  242. data/app/services/collavre/comments/topic_mutation.rb +20 -0
  243. data/app/services/collavre/creatives/breadcrumb_resolver.rb +1 -1
  244. data/app/services/collavre/creatives/comment_badge_index.rb +299 -27
  245. data/app/services/collavre/creatives/reorderer.rb +15 -3
  246. data/app/services/collavre/creatives/tree_builder.rb +1 -1
  247. data/app/services/collavre/creatives/tree_formatter.rb +5 -6
  248. data/app/services/collavre/creatives/workspace_path_resolver.rb +50 -0
  249. data/app/services/collavre/creatives/workspace_tree_builder.rb +92 -0
  250. data/app/services/collavre/crons/recurring_topic_tasks.rb +23 -0
  251. data/app/services/collavre/home_page_path.rb +55 -0
  252. data/app/services/collavre/html_text.rb +64 -0
  253. data/app/services/collavre/http_client.rb +11 -2
  254. data/app/services/collavre/inbox_reply_service.rb +1 -1
  255. data/app/services/collavre/markdown_converter.rb +13 -1
  256. data/app/services/collavre/orchestration/agent_orchestrator.rb +776 -81
  257. data/app/services/collavre/orchestration/arbiter.rb +48 -5
  258. data/app/services/collavre/orchestration/deferred_trigger_scope.rb +53 -0
  259. data/app/services/collavre/orchestration/delivery_record.rb +792 -0
  260. data/app/services/collavre/orchestration/loop_breaker.rb +10 -4
  261. data/app/services/collavre/orchestration/matcher.rb +132 -17
  262. data/app/services/collavre/orchestration/policy_resolver.rb +58 -10
  263. data/app/services/collavre/orchestration/scheduler.rb +10 -4
  264. data/app/services/collavre/orchestration/scheduling_hooks.rb +10 -0
  265. data/app/services/collavre/orchestration/selection.rb +43 -0
  266. data/app/services/collavre/orchestration/stuck_detector.rb +25 -112
  267. data/app/services/collavre/orchestration/task_coalescer.rb +355 -0
  268. data/app/services/collavre/orchestration/topic_slot.rb +73 -0
  269. data/app/services/collavre/system_events/context_builder.rb +60 -11
  270. data/app/services/collavre/system_events/dispatcher.rb +58 -12
  271. data/app/services/collavre/system_events/envelope.rb +73 -0
  272. data/app/services/collavre/system_events/vocabulary.rb +45 -0
  273. data/app/services/collavre/tools/creative_retrieval_service.rb +1 -1
  274. data/app/services/collavre/tools/cron_create_service.rb +32 -18
  275. data/app/services/collavre/tools/id_list.rb +38 -0
  276. data/app/services/collavre/tools/topic_authorizer.rb +61 -7
  277. data/app/services/collavre/tools/topic_branch_service.rb +85 -0
  278. data/app/services/collavre/tools/topic_create_service.rb +117 -0
  279. data/app/services/collavre/tools/topic_list_service.rb +101 -0
  280. data/app/services/collavre/tools/topic_message_create_service.rb +192 -0
  281. data/app/services/collavre/tools/topic_messages_service.rb +346 -0
  282. data/app/services/collavre/tools/topic_move_service.rb +108 -0
  283. data/app/services/collavre/tools/topic_selection.rb +59 -0
  284. data/app/services/collavre/tools/topic_update_service.rb +165 -0
  285. data/app/services/collavre/topic_branch_service.rb +39 -10
  286. data/app/services/collavre/topics/agent_resolver.rb +87 -0
  287. data/app/services/collavre/topics/char_budget.rb +70 -0
  288. data/app/services/collavre/topics/last_topic_preference_payload.rb +18 -0
  289. data/app/services/collavre/topics/message_page.rb +380 -0
  290. data/app/services/collavre/topics/message_page_markdown.rb +102 -0
  291. data/app/services/collavre/topics/message_scope.rb +61 -0
  292. data/app/services/collavre/topics/message_stats.rb +92 -0
  293. data/app/services/collavre/topics/move_blocker.rb +47 -0
  294. data/app/services/collavre/topics/next_name.rb +35 -0
  295. data/app/services/collavre/topics/pin_rejection.rb +25 -0
  296. data/app/services/collavre/topics/read_pointer_relocator.rb +71 -0
  297. data/app/services/collavre/topics/reserved_name.rb +18 -0
  298. data/app/services/collavre/topics/serializer.rb +92 -0
  299. data/app/services/collavre/topics/topic_destroy.rb +30 -0
  300. data/app/services/collavre/topics/topic_move.rb +80 -0
  301. data/app/services/collavre/topics/topic_move_effects.rb +35 -0
  302. data/app/services/collavre/topics/topic_mutation.rb +27 -0
  303. data/app/views/collavre/admin/integrations/_setting_row.html.erb +5 -8
  304. data/app/views/collavre/admin/settings/_system_tab.html.erb +7 -0
  305. data/app/views/collavre/agent_connections/show.html.erb +77 -0
  306. data/app/views/collavre/agent_gateways/_form.html.erb +71 -0
  307. data/app/views/collavre/agent_gateways/edit.html.erb +2 -0
  308. data/app/views/collavre/agent_gateways/index.html.erb +54 -0
  309. data/app/views/collavre/agent_gateways/new.html.erb +2 -0
  310. data/app/views/collavre/comments/_comments_popup.html.erb +32 -17
  311. data/app/views/collavre/comments/_empty_state.html.erb +33 -0
  312. data/app/views/collavre/comments/_empty_state_minimal.html.erb +7 -0
  313. data/app/views/collavre/comments/_feature_card.html.erb +35 -0
  314. data/app/views/collavre/comments/_list.html.erb +3 -2
  315. data/app/views/collavre/comments/_no_search_results.html.erb +3 -0
  316. data/app/views/collavre/creatives/_add_button.html.erb +17 -19
  317. data/app/views/collavre/creatives/_empty_state.html.erb +109 -0
  318. data/app/views/collavre/creatives/_inline_edit_form.html.erb +5 -5
  319. data/app/views/collavre/creatives/_tree_loading_placeholder.html.erb +16 -0
  320. data/app/views/collavre/creatives/_workspace_tree.html.erb +33 -0
  321. data/app/views/collavre/creatives/index.html.erb +79 -18
  322. data/app/views/collavre/desktop_setup/show.html.erb +114 -0
  323. data/app/views/collavre/features/index.html.erb +42 -0
  324. data/app/views/collavre/features/show.html.erb +52 -0
  325. data/app/views/collavre/invitation_mailer/invite.html.erb +1 -1
  326. data/app/views/collavre/invitation_mailer/invite.text.erb +1 -1
  327. data/app/views/collavre/invites/show.html.erb +14 -1
  328. data/app/views/collavre/landing/show.html.erb +5 -1
  329. data/app/views/collavre/shared/_link_creative_modal.html.erb +4 -1
  330. data/app/views/collavre/shared/navigation/_help_button.html.erb +3 -1
  331. data/app/views/collavre/shared/navigation/_panels.html.erb +1 -8
  332. data/app/views/collavre/shared/navigation/_search_form.html.erb +13 -2
  333. data/app/views/collavre/user_themes/index.html.erb +1 -1
  334. data/app/views/collavre/users/_llm_fields.html.erb +65 -0
  335. data/app/views/collavre/users/_org_chart_node.html.erb +1 -1
  336. data/app/views/collavre/users/edit_ai.html.erb +33 -31
  337. data/app/views/collavre/users/new.html.erb +4 -2
  338. data/app/views/collavre/users/new_ai.html.erb +14 -32
  339. data/app/views/collavre/users/show.html.erb +13 -1
  340. data/app/views/layouts/collavre/landing.html.erb +9 -4
  341. data/config/initializers/ruby_llm_openai_reasoning_content.rb +77 -0
  342. data/config/locales/admin.en.yml +3 -1
  343. data/config/locales/admin.ko.yml +3 -1
  344. data/config/locales/agent_gateway.en.yml +217 -0
  345. data/config/locales/agent_gateway.ko.yml +217 -0
  346. data/config/locales/ai_agent.en.yml +0 -1
  347. data/config/locales/ai_agent.ko.yml +0 -1
  348. data/config/locales/comments.en.yml +72 -30
  349. data/config/locales/comments.ko.yml +72 -30
  350. data/config/locales/creatives.en.yml +32 -1
  351. data/config/locales/creatives.ko.yml +32 -1
  352. data/config/locales/features.en.yml +140 -0
  353. data/config/locales/features.ko.yml +140 -0
  354. data/config/locales/invites.en.yml +3 -0
  355. data/config/locales/invites.ko.yml +3 -0
  356. data/config/locales/landing.en.yml +2 -1
  357. data/config/locales/landing.ko.yml +2 -1
  358. data/config/locales/tools.en.yml +20 -0
  359. data/config/locales/tools.ko.yml +20 -0
  360. data/config/locales/users.en.yml +15 -14
  361. data/config/locales/users.ko.yml +14 -14
  362. data/config/routes.rb +45 -19
  363. data/db/migrate/20250911084338_backfill_creative_in_comment_inbox_items.rb +9 -1
  364. data/db/migrate/20260724000000_add_creative_id_index_to_comments.rb +34 -0
  365. data/db/migrate/20260724000001_add_creative_private_id_index_to_comments.rb +50 -0
  366. data/db/migrate/20260724090000_add_async_comment_notification_state.rb +64 -0
  367. data/db/migrate/20260727000000_add_waiting_notice_scope_to_comments.rb +30 -0
  368. data/db/migrate/20260727010000_add_waiting_notice_scope_to_tasks.rb +26 -0
  369. data/db/migrate/20260805000000_add_creative_workspace_enabled_to_users.rb +5 -0
  370. data/db/migrate/20260807050000_add_dismissed_notices_to_users.rb +5 -0
  371. data/db/migrate/20260809000000_change_creative_workspace_enabled_default_to_true.rb +18 -0
  372. data/db/migrate/20260809000001_create_llm_models.rb +64 -0
  373. data/db/migrate/20260809000002_create_agent_gateways_and_workspaces.rb +35 -0
  374. data/db/migrate/20260809000003_hash_agent_workspace_callback_tokens.rb +44 -0
  375. data/db/migrate/20260809000004_encrypt_agent_workspace_manifest_tokens.rb +59 -0
  376. data/db/migrate/20260810000000_split_agent_workspace_identity_axes.rb +58 -0
  377. data/db/migrate/20260810000001_seed_codex_custom_proxy_llm_models.rb +28 -0
  378. data/db/migrate/20260810010000_add_last_visited_creative_to_users.rb +8 -0
  379. data/db/migrate/20260810020000_add_last_visited_creative_at_to_users.rb +5 -0
  380. data/db/migrate/20260810030000_add_last_visited_creative_navigation_order_to_users.rb +6 -0
  381. data/db/migrate/20260810060000_add_last_visited_creative_issued_sequence_to_users.rb +5 -0
  382. data/db/migrate/20260810100000_add_desktop_managed_to_agent_gateways.rb +40 -0
  383. data/db/migrate/20260810100001_add_desktop_preset_adapter_to_users.rb +38 -0
  384. data/db/migrate/20260811000000_make_agent_gateway_completion_key_nullable.rb +7 -0
  385. data/db/migrate/20260812000000_remove_workflow_columns_from_tasks.rb +101 -0
  386. data/db/migrate/20260812000001_remove_android_devices.rb +24 -0
  387. data/db/migrate/20260812010000_drop_inbox_items.rb +22 -0
  388. data/db/migrate/20260813000000_clear_claude_channel_presence_routing_expressions.rb +16 -0
  389. data/db/migrate/20260813000001_add_last_topic_revision_to_user_creative_preferences.rb +5 -0
  390. data/db/migrate/20260813000002_add_last_topic_save_order_to_user_creative_preferences.rb +6 -0
  391. data/db/migrate/20260813000003_add_last_topic_save_sequences_to_user_creative_preferences.rb +5 -0
  392. data/db/migrate/20260813000004_add_last_topic_save_fences_to_user_creative_preferences.rb +6 -0
  393. data/db/migrate/20260814000000_add_topic_to_comment_read_pointers.rb +79 -0
  394. data/db/migrate/20260814000001_add_creative_topic_private_id_index_to_comments.rb +37 -0
  395. data/db/migrate/20260816000000_add_watermark_index_to_comment_read_pointers.rb +27 -0
  396. data/db/migrate/20260816000001_add_private_recipient_topic_index_to_comments.rb +48 -0
  397. data/db/migrate/20260820000000_add_topic_assigned_at_to_comments.rb +13 -0
  398. data/db/seeds.rb +1 -51
  399. data/lib/collavre/engine.rb +108 -1
  400. data/lib/collavre/feature_card_registry.rb +168 -0
  401. data/lib/collavre/hashed_access_token_lookup.rb +22 -0
  402. data/lib/collavre/sensitive_request_silencer.rb +23 -0
  403. data/lib/collavre/user_extensions.rb +0 -2
  404. data/lib/collavre/version.rb +1 -1
  405. data/lib/collavre.rb +1 -0
  406. data/skills/collavre/SKILL.md +165 -0
  407. data/skills/collavre/package.json +8 -0
  408. data/skills/collavre/references/tool-reference.md +347 -0
  409. data/skills/collavre/scripts/collavre +634 -0
  410. data/skills/collavre/scripts/html_entities.js +24 -0
  411. metadata +228 -30
  412. data/app/controllers/collavre/api/v1/mobile/agent_events_controller.rb +0 -224
  413. data/app/controllers/collavre/api/v1/mobile/base_controller.rb +0 -95
  414. data/app/controllers/collavre/api/v1/mobile/devices_controller.rb +0 -31
  415. data/app/controllers/collavre/api/v1/mobile/voice_commands_controller.rb +0 -25
  416. data/app/controllers/collavre/inbox_items_controller.rb +0 -70
  417. data/app/controllers/collavre/typo_corrections_controller.rb +0 -39
  418. data/app/javascript/lib/__tests__/typo_correction.test.js +0 -192
  419. data/app/javascript/lib/typo_correction.js +0 -146
  420. data/app/javascript/modules/__tests__/typo_corrector.test.js +0 -365
  421. data/app/javascript/modules/creative_guide.js +0 -67
  422. data/app/javascript/modules/typo_corrector.js +0 -534
  423. data/app/jobs/collavre/inbox_summary_job.rb +0 -24
  424. data/app/mailers/collavre/inbox_mailer.rb +0 -19
  425. data/app/models/collavre/inbox_item.rb +0 -89
  426. data/app/services/collavre/comments/concerns/workflow_support.rb +0 -115
  427. data/app/services/collavre/comments/work_command.rb +0 -161
  428. data/app/services/collavre/comments/workflow_executor.rb +0 -276
  429. data/app/services/collavre/gemini_parent_recommender.rb +0 -77
  430. data/app/services/collavre/mobile/event_summarizer.rb +0 -40
  431. data/app/services/collavre/typo_corrector.rb +0 -188
  432. data/app/views/collavre/inbox_items/_item.html.erb +0 -18
  433. data/app/views/collavre/inbox_items/_items.html.erb +0 -3
  434. data/app/views/collavre/inbox_items/_list.html.erb +0 -7
  435. data/app/views/collavre/inbox_items/index.html.erb +0 -1
  436. data/app/views/collavre/inbox_mailer/daily_summary.html.erb +0 -11
  437. data/app/views/collavre/inbox_mailer/daily_summary.text.erb +0 -8
  438. data/app/views/collavre/users/typo_correction.html.erb +0 -50
  439. data/config/locales/mobile.en.yml +0 -16
  440. data/config/locales/mobile.ko.yml +0 -16
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # First-run flow for the desktop CLI Proxy. Credential generation and the
5
+ # local proxy process live in Tauri; this controller only persists the
6
+ # resulting gateway after verifying a short-lived, local-only handoff token.
7
+ class DesktopSetupController < ApplicationController
8
+ allow_unauthenticated_access only: %i[show create_account]
9
+
10
+ STEPS = %w[account install adapters ready].freeze
11
+ DESKTOP_GATEWAY_NAME = "Collavre Desktop CLI Proxy"
12
+ PRESETS = {
13
+ "claude" => [ "Claude Code", "collavre-desktop-claude-code@ai.local", "paperclip/claude_local" ],
14
+ "codex" => [ "Codex", "collavre-desktop-codex@ai.local", "paperclip/codex_local" ]
15
+ }.freeze
16
+
17
+ def show
18
+ @step = params[:step].to_s
19
+ @step = STEPS.first unless STEPS.include?(@step)
20
+ @user = Current.user
21
+ @account_required = !Collavre::User.where(system_admin: true).exists?
22
+ return if @account_required
23
+
24
+ # Recovery is entered without a step after native setup fails. An
25
+ # authenticated owner must resume at installation rather than see the
26
+ # sign-in-only account panel.
27
+ if @user&.system_admin?
28
+ @step = "install" if @step == "account"
29
+ return
30
+ end
31
+
32
+ # A desktop in open mode can have an ordinary user signed in when native
33
+ # proxy recovery opens this page. That user cannot mint a registration
34
+ # token, so keep them on the account step where they can sign out and use
35
+ # the owner's account instead of presenting a broken install action.
36
+ if @user
37
+ session[:return_to_after_authenticating] = collavre.desktop_setup_path(step: @step)
38
+ @step = "account"
39
+ return
40
+ end
41
+
42
+ # A failed native registration leaves the proxy installed but incomplete.
43
+ # Preserve the exact setup destination through login so the local owner
44
+ # can retry without deleting Keychain entries or app data.
45
+ @step = "install" if @step == "account"
46
+ session[:return_to_after_authenticating] = collavre.desktop_setup_path(step: @step)
47
+ redirect_to collavre.new_session_path
48
+ end
49
+
50
+ def create_account
51
+ return unless require_desktop_mode!
52
+ return unless require_loopback!
53
+
54
+ @user = Collavre::User.new(account_params)
55
+ Collavre::User.transaction do
56
+ unless @user.save
57
+ @step = "account"
58
+ @account_required = true
59
+ render :show, status: :unprocessable_entity
60
+ return
61
+ end
62
+
63
+ # The absence check is part of the UPDATE itself. Two overlapping local
64
+ # signups can both save, but only one can become the first administrator.
65
+ admin_exists = Collavre::User.where(system_admin: true).arel.exists
66
+ promoted = Collavre::User.where(id: @user.id)
67
+ .where(admin_exists.not)
68
+ .update_all(email_verified_at: Time.current, system_admin: true)
69
+ raise FirstAdministratorAlreadyCreated unless promoted == 1
70
+ end
71
+ start_new_session_for(@user)
72
+ redirect_to collavre.desktop_setup_path(step: :install)
73
+ rescue FirstAdministratorAlreadyCreated
74
+ redirect_to collavre.new_session_path, alert: t("collavre.desktop_setup.account.already_created")
75
+ end
76
+
77
+ def registration_token
78
+ return unless require_desktop_mode!
79
+ return unless require_loopback!
80
+ unless Current.user&.system_admin?
81
+ head :forbidden
82
+ return
83
+ end
84
+
85
+ render json: { token: registration_verifier.generate({ user_id: Current.user.id, expires_at: 5.minutes.from_now.to_i }) }
86
+ end
87
+
88
+ private
89
+
90
+ FirstAdministratorAlreadyCreated = Class.new(StandardError)
91
+
92
+ def account_params
93
+ params.require(:admin).permit(:name, :email, :password, :password_confirmation)
94
+ end
95
+
96
+ def registration_verifier
97
+ Rails.application.message_verifier("desktop-gateway-registration")
98
+ end
99
+
100
+ def require_loopback!
101
+ addr = IPAddr.new(request.remote_addr.to_s)
102
+ addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
103
+ return true if addr.loopback?
104
+
105
+ head :forbidden
106
+ false
107
+ rescue IPAddr::InvalidAddressError
108
+ head :forbidden
109
+ false
110
+ end
111
+
112
+ def require_desktop_mode!
113
+ return true if Rails.application.config.x.desktop_proxy_setup == true
114
+
115
+ head :not_found
116
+ false
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Authenticates the Rails child to the native shell before its webview is
5
+ # navigated to the local server. The secret is generated per launch by Tauri
6
+ # and exists only in this child process environment.
7
+ class DesktopSidecarHealthController < ActionController::API
8
+ def show
9
+ return unless require_loopback!
10
+
11
+ secret = ENV.fetch("COLLAVRE_DESKTOP_SIDECAR_SECRET", "")
12
+ return head :not_found if secret.blank?
13
+
14
+ challenge = params.require(:challenge)
15
+ digest = OpenSSL::HMAC.digest("SHA256", secret, challenge)
16
+ render plain: Base64.urlsafe_encode64(digest, padding: false), content_type: "text/plain"
17
+ rescue ActionController::ParameterMissing
18
+ head :bad_request
19
+ end
20
+
21
+ private
22
+
23
+ def require_loopback!
24
+ addr = IPAddr.new(request.remote_addr.to_s)
25
+ addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
26
+ return true if addr.loopback?
27
+
28
+ head :forbidden
29
+ false
30
+ rescue IPAddr::InvalidAddressError
31
+ head :forbidden
32
+ false
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Public, server-rendered guide pages for the empty-chat feature cards.
5
+ #
6
+ # These double as landing content, so they are readable signed out and share
7
+ # the landing layout/stylesheet. All copy lives in config/locales/features.*.yml
8
+ # under collavre.features.pages.<key>; the registry only supplies which keys
9
+ # exist and in what order.
10
+ class FeaturesController < ApplicationController
11
+ allow_unauthenticated_access
12
+ layout "collavre/landing"
13
+
14
+ def index
15
+ @cards = Collavre::FeatureCardRegistry.with_builtin_guide
16
+ end
17
+
18
+ def show
19
+ @card = Collavre::FeatureCardRegistry.find(params[:key])
20
+ # An unknown key, or a card documented by a vendor engine elsewhere, has no
21
+ # page here — both are a 404 rather than a blank shell of headings.
22
+ raise ActiveRecord::RecordNotFound unless @card&.builtin_guide?
23
+
24
+ @page = self.class.page_content(@card.key)
25
+ end
26
+
27
+ # Reads a page's copy out of i18n, normalizing the parts a translator can
28
+ # legitimately omit. Missing translations come back from I18n as a String
29
+ # rather than the Array/Hash shape the view walks, so coerce before render
30
+ # instead of letting the view guard every access.
31
+ def self.page_content(key)
32
+ scope = "collavre.features.pages.#{key}"
33
+
34
+ {
35
+ title: I18n.t("#{scope}.title", default: ""),
36
+ tagline: I18n.t("#{scope}.tagline", default: ""),
37
+ meta_description: I18n.t("#{scope}.meta_description", default: ""),
38
+ sections: array_of_hashes(I18n.t("#{scope}.sections", default: [])),
39
+ tips: array_of_strings(I18n.t("#{scope}.tips", default: []))
40
+ }
41
+ end
42
+
43
+ def self.array_of_hashes(value)
44
+ return [] unless value.is_a?(Array)
45
+
46
+ value.select { |entry| entry.is_a?(Hash) && entry[:body].present? }
47
+ end
48
+
49
+ def self.array_of_strings(value)
50
+ return [] unless value.is_a?(Array)
51
+
52
+ value.select { |entry| entry.is_a?(String) && entry.present? }
53
+ end
54
+
55
+ private_class_method :array_of_hashes, :array_of_strings
56
+ end
57
+ end
@@ -1,5 +1,10 @@
1
1
  module Collavre
2
2
  class InvitesController < ApplicationController
3
+ LINK_PREVIEW_USER_AGENT_PATTERN = /
4
+ bot\b|crawler\b|spider\b|preview\b|scrap(?:e|er)?\b|
5
+ facebookexternalhit|whatsapp|iframely|embedly
6
+ /ix
7
+
3
8
  allow_unauthenticated_access only: :show
4
9
  before_action :set_invitation, only: :show
5
10
 
@@ -17,11 +22,17 @@ module Collavre
17
22
  end
18
23
 
19
24
  def show
20
- @invitation.update(clicked_at: Time.current) unless @invitation.clicked_at
25
+ return if @invitation.clicked_at || link_preview_crawler?
26
+
27
+ @invitation.update(clicked_at: Time.current)
21
28
  end
22
29
 
23
30
  private
24
31
 
32
+ def link_preview_crawler?
33
+ request.user_agent.to_s.match?(LINK_PREVIEW_USER_AGENT_PATTERN)
34
+ end
35
+
25
36
  def set_invitation
26
37
  @invitation = Invitation.find_by_token_for(:invite, params[:token])
27
38
  rescue ActiveSupport::MessageVerifier::InvalidSignature
@@ -0,0 +1,8 @@
1
+ module Collavre
2
+ class LlmModelsController < ApplicationController
3
+ def destroy
4
+ LlmModel.find_by(id: params[:id])&.destroy!
5
+ head :no_content
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,20 @@
1
+ module Collavre
2
+ class NoticesController < ApplicationController
3
+ def dismiss
4
+ key = params[:key].to_s
5
+
6
+ Current.user.with_lock do
7
+ dismissed = Current.user.dismissed_notices || []
8
+ Current.user.update!(dismissed_notices: (dismissed + [ key ]).uniq)
9
+ end
10
+
11
+ render json: { success: true, dismissed_notices: Current.user.dismissed_notices }
12
+ end
13
+
14
+ def restore_all
15
+ Current.user.update!(dismissed_notices: [])
16
+
17
+ render json: { success: true, dismissed_notices: Current.user.dismissed_notices }
18
+ end
19
+ end
20
+ end
@@ -2,6 +2,21 @@
2
2
 
3
3
  module Collavre
4
4
  class TasksController < ApplicationController
5
+ def active_statuses
6
+ task_ids = params[:task_ids].to_s.split(",").map(&:to_i).reject(&:zero?)
7
+ return render json: { tasks: [] } if task_ids.empty?
8
+
9
+ tasks = Task.where(id: task_ids).includes(:creative)
10
+ results = tasks.filter_map do |task|
11
+ creative = task.creative || Creative.find_by(id: task.trigger_event_payload&.dig("creative", "id"))
12
+ next unless creative&.has_permission?(Current.user, :read)
13
+
14
+ { id: task.id, active: task.active? }
15
+ end
16
+
17
+ render json: { tasks: results }
18
+ end
19
+
5
20
  def cancel
6
21
  task = Task.find(params[:id])
7
22
  creative = task.creative || Creative.find_by(id: task.trigger_event_payload&.dig("creative", "id"))
@@ -10,10 +25,6 @@ module Collavre
10
25
  return head :forbidden
11
26
  end
12
27
 
13
- unless %w[running pending queued pending_approval delegated].include?(task.status)
14
- return head :unprocessable_entity
15
- end
16
-
17
28
  # These statuses count against the topic slot (occupying_topic_slot) yet no
18
29
  # live worker will run AiAgentJob's ensure-block drain for them:
19
30
  # - delegated / pending_approval already returned from the job holding the
@@ -25,8 +36,31 @@ module Collavre
25
36
  # cancelling the blocker leaves agent capacity and the next waiter stuck
26
37
  # until stuck recovery. release!/dequeue are idempotent (dequeue is bounded
27
38
  # by topic_at_capacity?), so a racing live worker that also drains is harmless.
28
- held_slot_without_worker = %w[pending delegated pending_approval].include?(task.status)
29
- task.update!(status: "cancelled")
39
+ previous_status = task.cancel_if_active!
40
+ return head :unprocessable_entity unless previous_status
41
+
42
+ held_slot_without_worker = Task::HELD_SLOT_WITHOUT_WORKER.include?(previous_status)
43
+
44
+ # The third door a waiter leaves the queue through without ever being
45
+ # promoted — "queued" is in the whitelist above, so the user's own stop
46
+ # button is one of them. Its "task" notice names this task, and a notice
47
+ # naming a task that is no longer queued cancels nothing when dismissed:
48
+ # a stop button for work that can no longer be stopped. Nothing else
49
+ # collects it either — the task is never promoted, so
50
+ # cleanup_waiter_notice! never runs for it, and the drained sweep needs
51
+ # the topic queue to empty, which any sibling waiter prevents.
52
+ Comment.remove_waiter_notices!(
53
+ creative_id: task.creative_id, topic_id: task.topic_id, task_ids: task.id
54
+ )
55
+
56
+ # With coalescing on this waiter had no notice of its own — the topic's
57
+ # shared one spoke for it, and the call above deliberately does not touch
58
+ # that kind. Same stranding, one row over.
59
+ if task.creative_id
60
+ Comment.remove_stranded_waiting_notices!(
61
+ creative_id: task.creative_id, topic_id: task.topic_id
62
+ )
63
+ end
30
64
 
31
65
  if held_slot_without_worker && task.agent
32
66
  Collavre::Orchestration::ResourceTracker.for(task.agent).release!(task.id)
@@ -1,5 +1,8 @@
1
1
  module Collavre
2
2
  class TopicsController < ApplicationController
3
+ rescue_from Topics::TopicMove::SourceChangedError, with: :render_source_changed
4
+ rescue_from Topics::TopicMove::MoveBlockedError, with: :render_active_tasks
5
+
3
6
  include Collavre::CreativePermissionGuard
4
7
 
5
8
  before_action :set_creative
@@ -10,7 +13,11 @@ module Collavre
10
13
  def index
11
14
  is_owner = @creative.user == Current.user
12
15
  can_manage = @creative.has_permission?(Current.user, :admin) || is_owner
13
- can_create_topic = can_manage || @creative.has_permission?(Current.user, :write)
16
+ # Mirrors require_creative_write! — the gate on #create and
17
+ # #set_primary_agent. Reported as two capabilities because the client gates
18
+ # two separate controls on it, and only #update/#destroy/#move/#reorder
19
+ # need :admin.
20
+ can_write = can_manage || @creative.has_permission?(Current.user, :write)
14
21
 
15
22
  # Eagerly ensure Main (and System for inboxes) exist BEFORE loading
16
23
  # active_topics. Otherwise the first inbox visit sees no System topic in
@@ -21,23 +28,20 @@ module Collavre
21
28
  system_topic = @creative.inbox? ? @creative.system_topic(fallback_user: Current.user) : nil
22
29
 
23
30
  active_topics = @creative.topics.active.includes(primary_agent: { avatar_attachment: :blob }).order(:created_at).to_a
24
- archived_topics = @creative.topics.archived.order(:created_at)
25
-
26
- last_topic_id = if Current.user
27
- UserCreativePreference
28
- .where(user_id: Current.user.id, creative_id: @creative.id)
29
- .pick(:last_topic_id)
30
- end
31
+ archived_topics = @creative.topics.archived.includes(primary_agent: { avatar_attachment: :blob }).order(:created_at).to_a
32
+ unread_counts_by_topic = Creatives::CommentBadgeIndex.new(user: Current.user)
33
+ .unread_counts_by_topic(@creative)
31
34
 
32
35
  system_topic_id = system_topic&.id
33
36
  main_topic_id = main_topic.id
34
37
 
35
38
  render json: {
36
- topics: active_topics.map { |t| topic_json(t) },
37
- archived_topics: archived_topics,
39
+ topics: active_topics.map { |t| topic_json(t, unread_count: unread_counts_by_topic.fetch(t.id, 0)) },
40
+ archived_topics: archived_topics.map { |t| topic_json(t, unread_count: unread_counts_by_topic.fetch(t.id, 0)) },
38
41
  can_manage: can_manage,
39
- can_create_topic: can_create_topic,
40
- last_topic_id: last_topic_id,
42
+ can_create_topic: can_write,
43
+ can_set_primary_agent: can_write,
44
+ **Topics::LastTopicPreferencePayload.new(creative: @creative, user: Current.user).call,
41
45
  is_inbox: @creative.inbox?,
42
46
  system_topic_id: system_topic_id,
43
47
  main_topic_id: main_topic_id,
@@ -46,15 +50,27 @@ module Collavre
46
50
  end
47
51
 
48
52
  def create
53
+ requested_agent = params[:agent_id].present? ? User.find_by(id: params[:agent_id]) : nil
54
+
55
+ # No topic yet, so a Claude Channel agent is rejected on an inbox creative:
56
+ # a topic created here never carries a session_id, which is the only place
57
+ # Matcher#eligible_in_inbox? will route one.
58
+ rejection = requested_agent&.ai_user? &&
59
+ Topic.primary_agent_rejection(@creative, requested_agent)
60
+ if rejection
61
+ render json: { errors: [ unassignable_agent_message(requested_agent, rejection) ] },
62
+ status: :unprocessable_entity and return
63
+ end
64
+
49
65
  topic = @creative.topics.build(topic_params)
50
66
  topic.user = Current.user
51
67
 
52
68
  Topic.transaction do
53
69
  if topic.save
54
70
  agent = nil
55
- if params[:agent_id].present?
56
- agent = User.find_by(id: params[:agent_id])
57
- topic.set_primary_agent!(agent) if agent&.ai_user?
71
+ if requested_agent&.ai_user?
72
+ agent = requested_agent
73
+ topic.set_primary_agent!(agent)
58
74
  end
59
75
 
60
76
  # Move comments to the new topic if comment_ids provided
@@ -89,7 +105,7 @@ module Collavre
89
105
  def update
90
106
  topic = @creative.topics.find(params[:id])
91
107
 
92
- if topic.update(topic_params)
108
+ if mutate_locked_topic(topic) { |current_topic| current_topic.update(topic_params) }
93
109
  broadcast_topic_event("updated", topic: topic_json(topic))
94
110
  render json: topic_json(topic)
95
111
  else
@@ -104,9 +120,9 @@ module Collavre
104
120
  render json: { error: I18n.t("collavre.topics.cannot_delete_main") }, status: :unprocessable_entity and return
105
121
  end
106
122
 
107
- topic_id = topic.id
108
- topic_name = topic.name
109
- topic.destroy
123
+ topic_id, topic_name = Topics::TopicDestroy.new(
124
+ topic: topic, source_creative: @creative
125
+ ).call
110
126
 
111
127
  # Best-effort orphaned-cron notice. Must never break the core deletion:
112
128
  # if it raises, swallow + log so broadcast/head still run.
@@ -140,31 +156,37 @@ module Collavre
140
156
  render json: { error: I18n.t("collavre.topics.move.duplicate_name", name: topic.name) }, status: :unprocessable_entity and return
141
157
  end
142
158
 
143
- Topic.transaction do
144
- topic.comments.update_all(creative_id: target_creative.id)
145
- topic.update!(creative: target_creative)
159
+ # A Claude Channel session topic is looked up as
160
+ # inbox.topics.find_by(primary_agent_id:, session_id:) — re-parenting it
161
+ # takes it out of that scope, so the next registration would fork a second
162
+ # topic and Matcher#eligible_in_inbox? would stop routing the session
163
+ # agent. Same identity that #set_primary_agent refuses to rewrite.
164
+ if topic.session_id.present?
165
+ render json: { error: I18n.t("collavre.topics.move.session_topic_locked") }, status: :unprocessable_entity and return
146
166
  end
147
167
 
148
- # update_all above skips counter-cache callbacks, so recompute
149
- # comments_count on both sides after re-parenting the comments.
150
- Creative.reset_counters(source_creative.id, :comments)
151
- Creative.reset_counters(target_creative.id, :comments)
152
-
153
- broadcast_topic_event("deleted", topic_id: topic.id)
154
- broadcast_topic_event("created", creative: target_creative, topic: topic.slice(:id, :name))
168
+ effects = Topics::TopicMoveEffects.new(topic, source_creative, target_creative)
169
+ released_agent, released_reason = Topics::TopicMove.new(
170
+ topic: topic, target_creative: target_creative
171
+ ).call(after_commit: effects.method(:call))
155
172
 
156
173
  render json: {
157
174
  success: true,
158
175
  topic: topic.slice(:id, :name),
159
176
  target_creative_id: target_creative.id,
160
177
  target_creative_name: target_creative.creative_snippet,
161
- missing_members: missing_members_for_move(source_creative, target_creative)
178
+ missing_members: missing_members_for_move(source_creative, target_creative),
179
+ released_primary_agent: released_agent && {
180
+ id: released_agent.id,
181
+ name: released_agent.display_name,
182
+ message: released_primary_agent_message(released_agent, released_reason, target_creative)
183
+ }
162
184
  }
163
185
  end
164
186
 
165
187
  def archive
166
188
  topic = @creative.topics.find(params[:id])
167
- topic.archive!
189
+ mutate_locked_topic(topic, &:archive!)
168
190
 
169
191
  broadcast_topic_event("archived", topic: topic.slice(:id, :name))
170
192
  render json: { success: true }
@@ -172,7 +194,7 @@ module Collavre
172
194
 
173
195
  def unarchive
174
196
  topic = @creative.topics.find(params[:id])
175
- topic.unarchive!
197
+ mutate_locked_topic(topic, &:unarchive!)
176
198
 
177
199
  broadcast_topic_event("unarchived", topic: topic.slice(:id, :name, :archived_at))
178
200
  render json: { success: true }
@@ -197,13 +219,44 @@ module Collavre
197
219
 
198
220
  def set_primary_agent
199
221
  topic = @creative.topics.find(params[:id])
222
+
223
+ # On a Claude Channel session topic, primary_agent_id is not a routing pin
224
+ # but part of the session identity: Matcher#eligible_in_inbox? needs it to
225
+ # route the session agent at all, and SessionProvisioner#find_or_create_topic
226
+ # reuses the topic by (primary_agent_id, session_id). Clearing or reassigning
227
+ # it would leave a live session unroutable and split the next registration
228
+ # into a second topic. Releasing a session is session teardown, not an
229
+ # avatar click.
230
+ if topic.session_id.present?
231
+ render json: { error: I18n.t("collavre.topics.session_topic_locked") },
232
+ status: :unprocessable_entity and return
233
+ end
234
+
235
+ # A blank agent_id clears the assignment. The primary agent is an exclusive
236
+ # assignment (it silences every other agent's ambient routing), so the user
237
+ # needs a way back to an unassigned topic — otherwise a single avatar click
238
+ # would permanently dedicate the topic to one agent.
239
+ if params[:agent_id].blank?
240
+ mutate_locked_topic(topic) { |current_topic| current_topic.set_primary_agent!(nil) }
241
+
242
+ broadcast_topic_event("updated", topic: topic_json_with_agent(topic, nil))
243
+
244
+ return render json: { success: true, topic: topic_json_with_agent(topic, nil) }
245
+ end
246
+
200
247
  agent = User.find_by(id: params[:agent_id])
201
248
 
202
249
  unless agent&.ai_user?
203
250
  render json: { error: I18n.t("collavre.topics.not_ai_agent") }, status: :unprocessable_entity and return
204
251
  end
205
252
 
206
- topic.set_primary_agent!(agent)
253
+ rejection = Topic.primary_agent_rejection(@creative, agent, topic: topic)
254
+ if rejection
255
+ render json: { error: unassignable_agent_message(agent, rejection) },
256
+ status: :unprocessable_entity and return
257
+ end
258
+
259
+ mutate_locked_topic(topic) { |current_topic| current_topic.set_primary_agent!(agent) }
207
260
 
208
261
  broadcast_topic_event("updated", topic: topic_json_with_agent(topic, agent))
209
262
 
@@ -212,10 +265,43 @@ module Collavre
212
265
 
213
266
  private
214
267
 
268
+ def render_source_changed(error)
269
+ render json: { error: error.message }, status: :forbidden
270
+ end
271
+
272
+ def render_active_tasks(error)
273
+ render json: { error: error.message }, status: :unprocessable_entity
274
+ end
275
+
215
276
  def set_creative
216
277
  @creative = Creative.find(params[:creative_id]).effective_origin
217
278
  end
218
279
 
280
+ def mutate_locked_topic(topic, &) = Topics::TopicMutation.new(topic: topic, source_creative: @creative).call(&)
281
+
282
+ # A move can now release the pin for either reason, and the advice differs:
283
+ # sharing the target creative fixes a missing-permission release, but a
284
+ # Claude Channel session agent stays confined to its own session topic no
285
+ # matter what is shared — telling the user to share and re-assign would send
286
+ # them after a fix that cannot work.
287
+ def released_primary_agent_message(agent, rejection, target_creative)
288
+ key = if rejection == :session_agent_outside_session_topic
289
+ "collavre.topics.move.primary_agent_released_session_agent"
290
+ else
291
+ "collavre.topics.move.primary_agent_released"
292
+ end
293
+
294
+ I18n.t(key, agent: agent.display_name, creative: target_creative.creative_snippet)
295
+ end
296
+
297
+ def unassignable_agent_message(agent, rejection)
298
+ if rejection == :session_agent_outside_session_topic
299
+ I18n.t("collavre.topics.session_agent_not_assignable", name: agent.display_name)
300
+ else
301
+ I18n.t("collavre.topics.agent_no_creative_access", name: agent.display_name)
302
+ end
303
+ end
304
+
219
305
  # When a topic moves to a different creative, members who had access on the
220
306
  # source creative may lose visibility on the target. Returns the human
221
307
  # members who are effectively present on the source (owner + feedback-or-
@@ -257,36 +343,27 @@ module Collavre
257
343
  end
258
344
  end
259
345
 
346
+
260
347
  def topic_params
261
348
  params.require(:topic).permit(:name)
262
349
  end
263
350
 
264
351
  def generate_next_topic_name
265
- prefix = I18n.t("collavre.topics.default_name_prefix")
266
- existing_numbers = @creative.topics.active
267
- .where("name LIKE ?", "#{Topic.sanitize_sql_like(prefix)}%")
268
- .pluck(:name)
269
- .filter_map { |n|
270
- suffix = n.delete_prefix(prefix)
271
- suffix.match?(/\A\d+\z/) ? suffix.to_i : nil
272
- }
273
-
274
- next_number = (existing_numbers.max || 0) + 1
275
- "#{prefix}#{next_number}"
352
+ Topics::NextName.for(@creative)
276
353
  end
277
354
 
278
- def topic_json(topic)
279
- data = topic.slice(:id, :name, :source_topic_id)
280
- if topic.primary_agent
281
- data[:primary_agent] = agent_json(topic.primary_agent)
282
- end
283
- data
355
+ # Delegated to Topics::Serializer so the topic MCP tools, which broadcast the
356
+ # same events from outside a request, cannot describe a topic differently
357
+ # from this controller.
358
+ def topic_json(topic, unread_count: nil)
359
+ Topics::Serializer.call(topic, unread_count: unread_count)
284
360
  end
285
361
 
362
+ # Always carries a :primary_agent key, explicitly nil when cleared. The client
363
+ # merges this payload into its cached topic, so an omitted key would leave a
364
+ # stale avatar on screen instead of removing it.
286
365
  def topic_json_with_agent(topic, agent)
287
- data = topic.slice(:id, :name, :source_topic_id)
288
- data[:primary_agent] = agent_json(agent)
289
- data
366
+ Topics::Serializer.with_agent(topic, agent)
290
367
  end
291
368
 
292
369
  def agent_json(agent)