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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Tools
5
+ # Turns the `topic_ids` parameter of the read tools into topics, and reports
6
+ # the ones it could not turn into topics instead of raising.
7
+ #
8
+ # A caller asking about three topics has usually copied the ids out of a
9
+ # conversation, so one of them being stale or unshared is ordinary. Failing
10
+ # the whole call there would throw away the two that worked and give no way
11
+ # to tell which id was the bad one.
12
+ module TopicSelection
13
+ # A ceiling on topics per call, not on messages — that is max_chars' job.
14
+ # This one exists because each topic costs a header, a totals query and a
15
+ # window query, and a caller that pastes forty ids has stopped planning
16
+ # and started dumping.
17
+ MAX_TOPICS = 20
18
+
19
+ module_function
20
+
21
+ # Returns [topics, errors], preserving the caller's id order so a numbered
22
+ # request reads back in the order it was asked.
23
+ def resolve(ids, user: Collavre::Current.user)
24
+ enforce_cap!(ids)
25
+ by_id = Topic.where(id: ids).index_by(&:id)
26
+
27
+ ids.each_with_object([ [], [] ]) do |id, (topics, errors)|
28
+ error = rejection_for(by_id[id], user)
29
+ error ? errors << { topic_id: id, error: error } : topics << by_id[id]
30
+ end
31
+ end
32
+
33
+ # An oversized batch is an error, not a trim. Silently keeping the first
34
+ # twenty returns a response that looks complete — every topic asked for
35
+ # that appears, appears in full — while whole conversations are missing
36
+ # with nothing in the payload that says so, and a caller summarizing
37
+ # "all of these" would report on a subset believing it had them all.
38
+ # Unreadable ids are still reported per topic rather than raised: those
39
+ # the caller can see and act on, one entry each.
40
+ def enforce_cap!(ids)
41
+ return if ids.size <= MAX_TOPICS
42
+
43
+ raise ArgumentError,
44
+ "#{ids.size} topics requested but at most #{MAX_TOPICS} can be read per call. " \
45
+ "Split the ids across several calls."
46
+ end
47
+
48
+ # Deliberately the same message for missing and unreadable. Distinguishing
49
+ # them would let a caller probe which topic ids exist on creatives they
50
+ # cannot see.
51
+ def rejection_for(topic, user)
52
+ return "Topic not found or not readable" if topic.nil?
53
+ return nil if TopicAuthorizer.readable?(topic, user: user)
54
+
55
+ "Topic not found or not readable"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,165 @@
1
+ module Collavre
2
+ require "sorbet-runtime"
3
+ require "rails_mcp_engine"
4
+ module Tools
5
+ # Renames a topic, archives/unarchives it, or changes its primary agent.
6
+ #
7
+ # One tool rather than three because these are the three ways a topic changes
8
+ # after it exists, and they are routinely done together — an agent finishing a
9
+ # unit of work archives the topic and releases the pin in the same breath.
10
+ # They do not share a permission level, so each field is authorized for what
11
+ # it actually does.
12
+ class TopicUpdateService
13
+ extend T::Sig
14
+ extend ToolMeta
15
+
16
+ tool_name "topic_update"
17
+ tool_description <<~DESC.strip
18
+ Rename a topic, archive or unarchive it, or set/clear its primary agent.
19
+ Pass only the fields you want to change.
20
+
21
+ Archiving is how a finished thread is put away: the topic leaves the
22
+ active sidebar but keeps every message, and topic_messages can still read
23
+ it by id. Archive a topic when its conversation has reached its
24
+ conclusion, rather than reusing it for the next subject — a topic is one
25
+ thread of one conclusion, and it is also the concurrency unit, so reusing
26
+ one serializes unrelated work behind it.
27
+
28
+ primary_agent pins one agent to the topic, EXCLUSIVELY: the pinned agent
29
+ alone answers ambient messages and no other agent responds. The agent must
30
+ already have feedback permission or better on the creative. Pass "none" to
31
+ clear the pin and let normal routing resume. Accepts an agent id, email,
32
+ or exact name; private agents already shared on this creative resolve too.
33
+
34
+ Permissions: renaming needs admin on the creative (a rename changes what
35
+ everyone else's links and habits point at); archiving and pin changes need
36
+ write. Main and an inbox's System topic are reserved: their names and
37
+ archive state cannot change, and those names cannot be assigned to another
38
+ topic, but their pin can. A Claude Channel session topic's pin is part of
39
+ its session identity and cannot be changed here.
40
+ DESC
41
+
42
+ tool_param :topic_id, description: "The topic to update."
43
+ tool_param :name, description: "New name. Must be unique on the creative. Requires admin permission.", required: false
44
+ tool_param :archived, description: "true archives the topic (hides it from the active list, keeps all messages); false unarchives it.", required: false
45
+ tool_param :primary_agent, description: "Agent to pin — id, email, or exact name. Pass \"none\" to clear the pin. The pin is exclusive: only this agent answers in the topic.", required: false
46
+
47
+ sig do
48
+ params(
49
+ topic_id: Integer,
50
+ name: T.nilable(String),
51
+ archived: T.nilable(T::Boolean),
52
+ primary_agent: T.nilable(String)
53
+ ).returns(T::Hash[Symbol, T.untyped])
54
+ end
55
+ def call(topic_id:, name: nil, archived: nil, primary_agent: nil)
56
+ Current.user || raise("Current.user is required")
57
+ topic = Topic.find(topic_id)
58
+
59
+ # Authorize before resolving the agent: resolution reports whether a name
60
+ # matches an accessible agent, and a caller with no rights on the topic
61
+ # should not be able to ask.
62
+ authorize!(topic, name)
63
+ changed = requested(name, archived, primary_agent)
64
+ raise ArgumentError, "Nothing to update: pass name, archived, or primary_agent" if changed.empty?
65
+
66
+ agent = apply(topic, name, archived, primary_agent)
67
+ broadcast_all(topic.reload, changed, archived, agent)
68
+
69
+ Topics::Serializer.for_tool(topic).merge(changed: changed)
70
+ end
71
+
72
+ private
73
+
74
+ # A rename is gated at :admin to match TopicsController#update. Everything
75
+ # else here is a :write action, so the stricter gate is only applied when a
76
+ # rename is actually requested — otherwise archiving would silently need
77
+ # admin through this door and not through the UI.
78
+ def authorize!(topic, name)
79
+ name.present? ? TopicAuthorizer.authorize_admin!(topic) : TopicAuthorizer.authorize_write!(topic)
80
+ end
81
+
82
+ def requested(name, archived, primary_agent)
83
+ changed = []
84
+ changed << "name" if name.present?
85
+ changed << "archived" unless archived.nil?
86
+ changed << "primary_agent" unless primary_agent.nil?
87
+ changed
88
+ end
89
+
90
+ # Every requested field is checked before any of them is written, and the
91
+ # writes share one transaction. A call that renames, archives, and pins an
92
+ # agent the creative has not shared with must report failure over a topic
93
+ # that is still named what it was — not one that was quietly renamed and
94
+ # archived on the way to the error. Nothing broadcasts until the whole set
95
+ # has committed, for the same reason.
96
+ def apply(topic, name, archived, primary_agent)
97
+ agent = nil
98
+ Topic.transaction do
99
+ topic.lock!
100
+ authorize!(topic, name)
101
+ reject_reserved_mutation!(topic, name, archived)
102
+ agent = resolve_agent(topic, primary_agent)
103
+ topic.update!(name: name) if name.present?
104
+ set_archived(topic, archived) unless archived.nil?
105
+ topic.set_primary_agent!(agent) unless primary_agent.nil?
106
+ end
107
+
108
+ agent
109
+ end
110
+
111
+ def resolve_agent(topic, primary_agent)
112
+ change = Topics::AgentResolver.call(primary_agent, creative: topic.creative)
113
+ change ? validated_agent(topic, change) : nil
114
+ end
115
+
116
+ def reject_reserved_mutation!(topic, name, archived)
117
+ return unless name.present? || !archived.nil?
118
+ return unless reserved_topic?(topic) || Topics::ReservedName.reserved?(topic.creative, name)
119
+
120
+ raise ArgumentError,
121
+ "Reserved topic names cannot be assigned or changed, and reserved topics cannot be archived."
122
+ end
123
+
124
+ def reserved_topic?(topic)
125
+ Topics::ReservedName.reserved?(topic.creative, topic.name)
126
+ end
127
+
128
+ def set_archived(topic, archived)
129
+ archived ? topic.archive! : topic.unarchive!
130
+ end
131
+
132
+ # Mirrors TopicsController#set_primary_agent, including the session lock:
133
+ # on a Claude Channel session topic primary_agent_id is session identity,
134
+ # not a routing preference — Matcher needs it to route the agent at all and
135
+ # SessionProvisioner reuses the topic by it, so rewriting it would leave a
136
+ # live session unroutable and fork the next registration into a second topic.
137
+ def validated_agent(topic, agent_change)
138
+ raise ArgumentError, "This is a session topic; its agent assignment is locked." if topic.session_id.present?
139
+
140
+ agent = agent_change == Topics::AgentResolver::CLEAR ? nil : agent_change
141
+ reject_unassignable!(topic, agent)
142
+ agent
143
+ end
144
+
145
+ def broadcast_all(topic, changed, archived, agent)
146
+ broadcast(topic, "updated", topic: Topics::Serializer.call(topic)) if changed.include?("name")
147
+ broadcast(topic, archived ? "archived" : "unarchived", topic: topic.slice(:id, :name, :archived_at)) if changed.include?("archived")
148
+ broadcast(topic, "updated", topic: Topics::Serializer.with_agent(topic, agent)) if changed.include?("primary_agent")
149
+ end
150
+
151
+ def reject_unassignable!(topic, agent)
152
+ return if agent.nil?
153
+
154
+ rejection = Topic.primary_agent_rejection(topic.creative, agent, topic: topic)
155
+ return unless rejection
156
+
157
+ raise ArgumentError, Topics::PinRejection.message(agent, rejection)
158
+ end
159
+
160
+ def broadcast(topic, action, payload)
161
+ TopicsChannel.broadcast_to(topic.creative, { action: action, **payload })
162
+ end
163
+ end
164
+ end
165
+ end
@@ -18,15 +18,21 @@ module Collavre
18
18
  # does not apply.
19
19
  # auto_select: false omits user_id from the topic-created broadcast so
20
20
  # background/system branches do not hijack the owner's current selection.
21
- def call(comment_ids:, enforce_limit: true, auto_select: true)
21
+ # skip_approval_actions is reserved for system history copies that already
22
+ # selected an approval-free set but must tolerate a row becoming an approval
23
+ # prompt before the locked fetch. Interactive branches keep the default
24
+ # all-or-nothing rejection.
25
+ def call(comment_ids:, enforce_limit: true, auto_select: true, skip_approval_actions: false)
22
26
  comment_ids = Array(comment_ids).map(&:presence).compact.map(&:to_i)
23
27
  comment_ids = comment_ids.first(MAX_BRANCH_COMMENTS) if enforce_limit
24
28
  raise BranchError, I18n.t("collavre.comments.branch.no_selection") if comment_ids.empty?
25
29
 
26
- validate_permissions!
27
- originals = fetch_comments(comment_ids)
28
-
29
30
  Topic.transaction do
31
+ lock_source!
32
+ validate_permissions!
33
+ originals = fetch_comments(comment_ids)
34
+ originals = originals.reject(&:approval_action?) if skip_approval_actions
35
+ reject_approval_actions!(originals)
30
36
  @new_topic = create_branch_topic
31
37
  copy_comments(originals)
32
38
  end
@@ -40,6 +46,16 @@ module Collavre
40
46
 
41
47
  attr_reader :creative, :user, :source_topic, :name
42
48
 
49
+ def lock_source!
50
+ return unless source_topic
51
+
52
+ # The topic can move after a caller's preflight authorization. Pin its
53
+ # current creative under the same lock that protects the comment fetch so
54
+ # permissions and the branch destination cannot refer to stale state.
55
+ source_topic.lock!
56
+ @creative = source_topic.creative
57
+ end
58
+
43
59
  def validate_permissions!
44
60
  unless creative.has_permission?(user, :feedback)
45
61
  raise BranchError, I18n.t("collavre.comments.branch.not_authorized")
@@ -48,15 +64,22 @@ module Collavre
48
64
 
49
65
  def fetch_comments(comment_ids)
50
66
  scope = source_topic ? source_topic.comments.visible_to(user) : creative.comments.visible_to(user)
51
- comments = scope.where(id: comment_ids).order(:created_at).to_a
67
+ comments = scope.where(id: comment_ids).order(:created_at).lock.to_a
52
68
  if comments.length != comment_ids.length
53
69
  raise BranchError, I18n.t("collavre.comments.branch.comments_not_found")
54
70
  end
55
71
  comments
56
72
  end
57
73
 
74
+ def reject_approval_actions!(comments)
75
+ return unless comments.any?(&:approval_action?)
76
+
77
+ raise BranchError, I18n.t("collavre.comments.branch.approval_action_not_branchable")
78
+ end
79
+
58
80
  def create_branch_topic
59
81
  topic_name = name.presence || default_branch_name
82
+ Topics::ReservedName.reject!(creative, topic_name, error_class: BranchError)
60
83
 
61
84
  creative.topics.create!(
62
85
  name: topic_name,
@@ -88,6 +111,12 @@ module Collavre
88
111
  user_id: original.user_id,
89
112
  content: original.content,
90
113
  private: original.private,
114
+ # Carried with `private`, not separately from it: visible_to reads a
115
+ # private comment for its author *and* its approver, so copying the
116
+ # flag without the approver narrows who can see the copy. The user who
117
+ # selected the message can be the approver rather than the author, and
118
+ # would then get a branch reporting a message it cannot show them.
119
+ approver_id: original.approver_id,
91
120
  review_type: original.review_type,
92
121
  skip_default_user: true,
93
122
  skip_dispatch: true
@@ -98,13 +127,13 @@ module Collavre
98
127
  new_comment.quoted_comment_id = id_mapping[original.quoted_comment_id]
99
128
  end
100
129
 
130
+ # Attach before validation so an image-only comment still satisfies the
131
+ # Comment content-or-images invariant. Reuse the blobs without copying
132
+ # file data, as the post-save loop did previously.
133
+ new_comment.images.attach(original.images.map(&:blob)) if original.images.attached?
134
+
101
135
  new_comment.save!
102
136
  id_mapping[original.id] = new_comment.id
103
-
104
- # Share image blobs (no duplication of file data)
105
- original.images.each do |image|
106
- new_comment.images.attach(image.blob)
107
- end
108
137
  end
109
138
  end
110
139
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # Resolves the `primary_agent` string an MCP caller passes into a User.
6
+ #
7
+ # The UI picks an agent from a palette and sends an id; an agent calling a
8
+ # tool has read a name out of a conversation and has no palette to click.
9
+ # Accepting id / email / display name is what makes topic_create and
10
+ # topic_update usable without a preceding lookup round-trip.
11
+ #
12
+ # Candidates are the owned-or-searchable set plus agents already shared on
13
+ # the target Creative. The latter matches User.mentionable_for and the UI's
14
+ # agent palette. Resolving over every agent row would let a caller confirm a
15
+ # private agent's existence by name, and would let them pin one they are not
16
+ # allowed to see.
17
+ module AgentResolver
18
+ # Sentinel distinguishing "clear the pin" from "no change". A blank string
19
+ # cannot carry that difference on its own: topic_update omitting the
20
+ # parameter and topic_update asking to unassign both arrive as falsy.
21
+ CLEAR = :clear
22
+
23
+ # What a caller writes to mean "unassign". Spelled out because an agent
24
+ # that guesses will guess one of these.
25
+ CLEAR_TOKENS = %w[none clear null nil unassign].freeze
26
+
27
+ class AmbiguousAgentError < ArgumentError; end
28
+ class UnknownAgentError < ArgumentError; end
29
+
30
+ module_function
31
+
32
+ # Returns nil (no change), CLEAR (unassign), or a User.
33
+ def call(value, actor: Collavre::Current.user, creative: nil)
34
+ token = value.to_s.strip
35
+ return nil if value.nil?
36
+ return CLEAR if token.empty? || CLEAR_TOKENS.include?(token.downcase)
37
+
38
+ candidates = candidates_for(actor, creative)
39
+ find_by_id(candidates, token) ||
40
+ find_by_email(candidates, token) ||
41
+ find_by_name(candidates, token) ||
42
+ raise(UnknownAgentError, unknown_message(token))
43
+ end
44
+
45
+ def candidates_for(actor, creative)
46
+ accessible = User.accessible_ai_agents_for(actor)
47
+ return accessible unless creative
48
+
49
+ shared = User.mentionable_for(creative).ai_agents
50
+ base = User.ai_agents
51
+ base.where(id: accessible.reorder(nil).select(:id))
52
+ .or(base.where(id: shared.reorder(nil).select(:id)))
53
+ .distinct.order(:name)
54
+ end
55
+
56
+ def find_by_id(candidates, token)
57
+ return nil unless token.match?(/\A\d+\z/)
58
+
59
+ candidates.find_by(id: token.to_i)
60
+ end
61
+
62
+ def find_by_email(candidates, token)
63
+ return nil unless token.include?("@")
64
+
65
+ candidates.find_by(email: token.downcase)
66
+ end
67
+
68
+ # Names are not unique, so an exact-match tie is reported with the ids
69
+ # rather than silently resolved to the first row — picking one would
70
+ # quietly dedicate a topic to the wrong agent.
71
+ def find_by_name(candidates, token)
72
+ matches = candidates.where(name: token).to_a
73
+ return matches.first if matches.one?
74
+ return nil if matches.empty?
75
+
76
+ raise AmbiguousAgentError,
77
+ "Multiple agents named #{token.inspect}: ids #{matches.map(&:id).join(', ')}. Pass the id instead."
78
+ end
79
+
80
+ def unknown_message(token)
81
+ "No accessible AI agent matches #{token.inspect}. " \
82
+ "Pass an agent id, email, or exact name; the agent must be searchable, created by you, " \
83
+ "or already shared on this creative."
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # A character cap and the unit it is measured in.
6
+ #
7
+ # The two travel together because neither is meaningful alone. A cap of
8
+ # 40,000 buys a different amount of conversation depending on whether the
9
+ # response is a markdown transcript or a JSON object, and budgeting in one
10
+ # unit while emitting the other is exactly how a cap gets exceeded by half
11
+ # again. Keeping them in one object means a caller cannot supply a cap and
12
+ # forget to say what it counts.
13
+ class CharBudget
14
+ FORMATS = %w[markdown json].freeze
15
+ DEFAULT_FORMAT = "markdown"
16
+
17
+ # Markdown's per-message overhead: the id, the ISO-8601 timestamp, the
18
+ # author separator and two newlines. Measured rather than guessed —
19
+ # content-only accounting hands this out for free, and a few hundred short
20
+ # messages is then an entire budget spent off the books.
21
+ ENVELOPE_CHARS = 36
22
+
23
+ # Markdown tags an AI author with a " (agent)" suffix on its byline. Eight
24
+ # characters is nothing on one message and a page's worth on a stretch of
25
+ # short agent turns, which is exactly the shape a busy topic has — and it
26
+ # is more than the margin the per-topic reserve leaves to absorb it.
27
+ AGENT_SUFFIX_CHARS = " (agent)".length
28
+
29
+ attr_reader :chars, :format
30
+
31
+ def self.normalize_format(value)
32
+ FORMATS.include?(value.to_s) ? value.to_s : DEFAULT_FORMAT
33
+ end
34
+
35
+ def initialize(chars: nil, format: DEFAULT_FORMAT)
36
+ @chars = chars
37
+ @format = self.class.normalize_format(format)
38
+ end
39
+
40
+ def unlimited? = @chars.nil?
41
+
42
+ def json? = @format == "json"
43
+
44
+ def with(chars:) = self.class.new(chars: chars, format: @format)
45
+
46
+ # What emitting this message costs, which is a property of the format
47
+ # rather than of the record.
48
+ #
49
+ # json is measured against the serialized form instead of estimated from
50
+ # it: the field names are a fixed ~120 characters the prose never sees,
51
+ # and the escaping ratio varies with the message, so no constant tracks
52
+ # both. The trailing character is the comma that joins it to the next one.
53
+ def cost(message)
54
+ return message.to_json.length + 1 if json?
55
+
56
+ ENVELOPE_CHARS + message[:content].to_s.length + message[:clip_notice].to_s.length +
57
+ message[:author].to_s.length +
58
+ (message[:agent] ? AGENT_SUFFIX_CHARS : 0)
59
+ end
60
+
61
+ # Whether a message of this cost still fits alongside what has been spent.
62
+ # Takes the cost rather than the message so the caller can charge what it
63
+ # measured instead of serializing the same message twice.
64
+ #
65
+ # Deciding before keeping is the point: appending first and checking
66
+ # afterwards let a message wider than the entire cap through untouched.
67
+ def fits?(cost, spent: 0) = unlimited? || (spent + cost) <= @chars
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,18 @@
1
+ module Collavre
2
+ module Topics
3
+ class LastTopicPreferencePayload
4
+ def initialize(creative:, user:)
5
+ @creative = creative
6
+ @user = user
7
+ end
8
+
9
+ def call
10
+ preference = UserCreativePreference.find_by(user_id: @user&.id, creative_id: @creative.id)
11
+ {
12
+ last_topic_id: preference&.last_topic_id,
13
+ last_topic_revision: preference && [ preference.id, preference.last_topic_revision ]
14
+ }
15
+ end
16
+ end
17
+ end
18
+ end