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,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # One newest-first window of a topic's messages.
6
+ #
7
+ # Newest-first is the ordering the window is *selected* in: offset 0 is the
8
+ # latest message. A conversation's recent end is the part worth reading when
9
+ # you can only afford part of it, and it is the part whose position does not
10
+ # change as the topic grows — an oldest-first offset would point somewhere
11
+ # different every time a message arrived.
12
+ class MessagePage
13
+ include Collavre::PublicAssetsHelper
14
+
15
+ DEFAULT_LIMIT = 50
16
+ MAX_LIMIT = 200
17
+
18
+ # Rendered oldest-first inside the window by default. The window is chosen
19
+ # from the newest end; how it reads once chosen is a separate question,
20
+ # and a summary written from a backwards transcript inverts cause and
21
+ # effect. Callers wanting the raw newest-first order pass order: "desc".
22
+ ORDERS = %w[asc desc].freeze
23
+ DEFAULT_ORDER = "asc"
24
+
25
+ Page = Struct.new(
26
+ :topic, :total_count, :total_chars, :offset, :limit,
27
+ :messages, :newest_message_id, :budget_exhausted, :budget,
28
+ :content_offset, :order, :next_cursor,
29
+ keyword_init: true
30
+ ) do
31
+ def returned_count = messages.size
32
+
33
+ # What the caller reads.
34
+ def returned_chars = messages.sum { |m| m[:content].to_s.length }
35
+
36
+ # What the caller is charged: what emitting these messages in the
37
+ # requested format costs, which is not what their prose measures.
38
+ def billed_chars = messages.sum { |m| budget.cost(m) }
39
+
40
+ def clipped_count = messages.count { |m| m[:clipped] }
41
+
42
+ def next_content_offset
43
+ messages.filter_map { |message| message[:next_content_offset] }.first ||
44
+ (content_offset if messages.empty? && next_cursor.present?)
45
+ end
46
+
47
+ # A clipped row is not consumed. The next call stays on the same row
48
+ # and advances inside its content; only a complete row advances offset.
49
+ def has_more? = next_cursor.present?
50
+
51
+ def next_offset
52
+ return offset if next_content_offset.present? || (messages.empty? && has_more?)
53
+
54
+ has_more? ? offset + returned_count : nil
55
+ end
56
+ end
57
+
58
+ # budget carries both the cap and the format it is counted in — see
59
+ # CharBudget for why those cannot be separate arguments.
60
+ def initialize(topic:, user:, **options)
61
+ options.assert_valid_keys(
62
+ :offset, :limit, :order, :include_system, :max_message_id, :content_offset, :cursor, :budget
63
+ )
64
+ @topic = topic
65
+ @user = user
66
+ @offset = [ options.fetch(:offset, 0).to_i, 0 ].max
67
+ @limit = clamp_limit(options.fetch(:limit, DEFAULT_LIMIT))
68
+ @order = normalized_order(options.fetch(:order, DEFAULT_ORDER))
69
+ @include_system = options.fetch(:include_system, false)
70
+ @max_message_id = options[:max_message_id]
71
+ @content_offset = [ options.fetch(:content_offset, 0).to_i, 0 ].max
72
+ @cursor = decode_cursor(options[:cursor])
73
+ @topic_assigned_before = @cursor&.fetch(:topic_assigned_before)
74
+ @budget = options.fetch(:budget, CharBudget.new)
75
+ end
76
+
77
+ def call
78
+ # CommentMoveService takes the destination topic lock before changing a
79
+ # comment's membership. Holding the same lock through the anchor and
80
+ # reads makes the assignment timestamp a commit-order boundary: a move
81
+ # is either wholly before this snapshot or wholly after it.
82
+ Topic.transaction do
83
+ lock_authorized_topic
84
+ build_page
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def build_page
91
+ anchor!
92
+ stat = MessageStats.for(
93
+ [ @topic ], user: @user, include_system: @include_system,
94
+ max_message_id: @max_message_id, topic_assigned_before: @topic_assigned_before
95
+ ).fetch(@topic.id)
96
+ window = fetch_window
97
+ content_offset = content_offset_for(window.first)
98
+ messages = within_budget(serialize_window(window.first(@limit), content_offset: content_offset))
99
+ next_cursor = encode_cursor(next_candidate(window, messages))
100
+
101
+ Page.new(
102
+ topic: @topic,
103
+ total_count: stat.count,
104
+ total_chars: stat.chars,
105
+ offset: @offset,
106
+ limit: @limit,
107
+ messages: @order == "asc" ? messages.reverse : messages,
108
+ newest_message_id: @max_message_id,
109
+ budget_exhausted: budget_exhausted?(messages, next_cursor),
110
+ content_offset: content_offset,
111
+ order: @order,
112
+ next_cursor: next_cursor,
113
+ budget: @budget
114
+ )
115
+ end
116
+
117
+ # Do not reload @topic here: TopicSelection already authorized its
118
+ # creative. If a concurrent TopicMove changed that creative, the exact
119
+ # authorized pair no longer locks or reads anything and fails closed.
120
+ def lock_authorized_topic
121
+ Topic.where(id: @topic.id, creative_id: @topic.creative_id).lock.pick(:id)
122
+ end
123
+
124
+ # An unanchored call picks its own anchor before it reads anything else,
125
+ # so the totals, the window and the advertised newest_message_id all
126
+ # describe one snapshot. Reading the anchor last instead lets a message
127
+ # that arrives mid-call land in the totals but not the window, or be
128
+ # named as newest without having been returned — and the caller then
129
+ # pages with an anchor that shifts every offset under it, repeating one
130
+ # message and skipping another.
131
+ #
132
+ # An empty topic anchors at 0 rather than nil, because nil is how this
133
+ # scope spells "unbounded": leaving it there would let a message arriving
134
+ # between the anchor and the totals query into both, and then advertise
135
+ # no anchor for the caller to pin the next page with. Zero is below every
136
+ # id, so it names the empty snapshot exactly, and Ruby's 0 is truthy —
137
+ # a caller passing it back gets the same bound rather than a fresh anchor.
138
+ def anchor!
139
+ @topic_assigned_before ||= Time.current
140
+ @max_message_id ||= MessageScope.for(
141
+ @topic, user: @user, include_system: @include_system,
142
+ topic_assigned_before: @topic_assigned_before
143
+ ).maximum(:id) || 0
144
+ end
145
+
146
+ def scope
147
+ @scope ||= MessageScope.for(
148
+ @topic, user: @user, include_system: @include_system,
149
+ max_message_id: @max_message_id, topic_assigned_before: @topic_assigned_before
150
+ )
151
+ end
152
+
153
+ # id is the tiebreak, not decoration: comments posted inside the same
154
+ # clock tick would otherwise order arbitrarily, and an unstable sort makes
155
+ # offset pagination drop and repeat rows across pages.
156
+ def fetch_window
157
+ relation = scope.includes(:user, images_attachments: :blob)
158
+ .order(created_at: :desc, id: :desc)
159
+ relation = after_cursor(relation) if @cursor
160
+ relation = relation.offset(@offset) unless @cursor
161
+ relation.limit(@limit + 1).to_a
162
+ end
163
+
164
+ def serialize_window(comments, content_offset:)
165
+ comments.map.with_index do |comment, index|
166
+ serialize(comment, content_offset: index.zero? ? content_offset : 0)
167
+ end
168
+ end
169
+
170
+ # A content offset belongs to the row named by the cursor. If that row
171
+ # leaves the topic, the inclusive keyset boundary correctly selects the
172
+ # next surviving row, but its content must start at zero.
173
+ def content_offset_for(first_comment)
174
+ return @content_offset unless @cursor
175
+ return @content_offset if cursor_row_unchanged?(first_comment)
176
+
177
+ 0
178
+ end
179
+
180
+ def cursor_row_unchanged?(comment)
181
+ comment&.id == @cursor.fetch(:id) &&
182
+ timestamp_micros(comment.updated_at) == @cursor.fetch(:updated_at_micros)
183
+ end
184
+
185
+ # The cursor names the first row not yet consumed, inclusively. This is
186
+ # what makes it survive that row itself being deleted or moved: the same
187
+ # ordered boundary still selects every older surviving row. During a
188
+ # clipped-row continuation it names the current row instead.
189
+ def next_candidate(window, messages)
190
+ return window.first if messages.empty? || messages.first[:clipped]
191
+
192
+ window[messages.length]
193
+ end
194
+
195
+ def after_cursor(relation)
196
+ relation.where(
197
+ "comments.created_at < :created_at OR " \
198
+ "(comments.created_at = :created_at AND comments.id <= :id)",
199
+ created_at: @cursor.fetch(:created_at), id: @cursor.fetch(:id)
200
+ )
201
+ end
202
+
203
+ def encode_cursor(comment)
204
+ return unless comment
205
+
206
+ [
207
+ timestamp_micros(comment.created_at), comment.id,
208
+ timestamp_micros(comment.updated_at), timestamp_micros(@topic_assigned_before)
209
+ ].join(":")
210
+ end
211
+
212
+ def decode_cursor(value)
213
+ return if value.blank?
214
+
215
+ match = /\A(\d{1,20}):(\d{1,20}):(\d{1,20}):(\d{1,20})\z/.match(value.to_s)
216
+ raise ArgumentError, "cursor is invalid" unless match
217
+
218
+ micros = Integer(match[1], 10)
219
+ id = Integer(match[2], 10)
220
+ updated_at_micros = Integer(match[3], 10)
221
+ topic_assigned_before_micros = Integer(match[4], 10)
222
+ unless [ micros, id, updated_at_micros, topic_assigned_before_micros ].all?(&:positive?)
223
+ raise ArgumentError, "cursor is invalid"
224
+ end
225
+
226
+ {
227
+ created_at: Time.at(micros / 1_000_000, micros % 1_000_000, :microsecond).utc,
228
+ id: id,
229
+ updated_at_micros: updated_at_micros,
230
+ topic_assigned_before: Time.at(
231
+ topic_assigned_before_micros / 1_000_000,
232
+ topic_assigned_before_micros % 1_000_000,
233
+ :microsecond
234
+ ).utc
235
+ }
236
+ rescue RangeError
237
+ raise ArgumentError, "cursor is invalid"
238
+ end
239
+
240
+ def timestamp_micros(time)
241
+ (time.to_r * 1_000_000).to_i
242
+ end
243
+
244
+ # Trims the newest-first window to the character budget. A message that
245
+ # does not fit ends the window rather than being cut in half — except for
246
+ # the first one, where stopping short would return zero rows at an offset
247
+ # that does have rows and leave the caller paging against it forever.
248
+ # That one is clipped to what the budget can pay for and exposes a content
249
+ # continuation. The row offset stays put until all of its prose is read.
250
+ def within_budget(messages)
251
+ return messages if @budget.unlimited?
252
+
253
+ spent = 0
254
+ kept = []
255
+ messages.each do |message|
256
+ cost = @budget.cost(message)
257
+ if @budget.fits?(cost, spent: spent)
258
+ kept << message
259
+ spent += cost
260
+ next
261
+ end
262
+
263
+ clipped = kept.empty? ? clip(message) : nil
264
+ kept << clipped if clipped
265
+ break
266
+ end
267
+ kept
268
+ end
269
+
270
+ # Returns nil when the budget cannot pay even for the envelope and the
271
+ # notice — there is no honest way to emit a fragment that small, and the
272
+ # empty page carries budget_limited to say so.
273
+ def clip(message)
274
+ content = message[:content].to_s
275
+ room = widest_prefix(message, content)
276
+ return nil if room <= 0
277
+
278
+ clipped(message, content, room)
279
+ end
280
+
281
+ # The longest prefix of the prose whose rendered cost still fits.
282
+ # Subtraction would do for markdown, where a character of content costs a
283
+ # character of output, but json escapes the prose while serializing it, so
284
+ # a slice can emit wider than it reads and arithmetic only gives an upper
285
+ # bound. Cost rises monotonically with the prefix, so the exact answer is
286
+ # a bisection — eighteen serializations at the largest cap, and only on
287
+ # the clip path, which is one message per call at most.
288
+ def widest_prefix(message, content)
289
+ low = 0
290
+ high = content.length
291
+ while low < high
292
+ mid = (low + high + 1) / 2
293
+ if @budget.fits?(@budget.cost(clipped(message, content, mid)))
294
+ low = mid
295
+ else
296
+ high = mid - 1
297
+ end
298
+ end
299
+ low
300
+ end
301
+
302
+ def clipped(message, content, take)
303
+ start = message[:content_offset].to_i
304
+ finish = start + take
305
+ total = message[:content_total_chars] || content.length
306
+
307
+ message.merge(
308
+ content: content[0, take],
309
+ content_offset: start,
310
+ content_end_offset: finish,
311
+ content_total_chars: total,
312
+ next_content_offset: finish,
313
+ clip_notice: clip_notice(finish, total),
314
+ clipped: true
315
+ )
316
+ end
317
+
318
+ # Counted against the budget like any other content, and phrased for the
319
+ # reader of the transcript: the caller sees the clip where the words stop,
320
+ # not only in a flag on the payload it may have rendered away.
321
+ def clip_notice(next_offset, total)
322
+ "…[clipped at #{next_offset} of #{total} chars — continue with content_offset: #{next_offset}]"
323
+ end
324
+
325
+ def serialize(comment, content_offset:)
326
+ content = serialized_content(comment)
327
+ start = [ content_offset, content.length ].min
328
+ message = {
329
+ id: comment.id,
330
+ author: comment.user&.display_name || "system",
331
+ author_id: comment.user_id,
332
+ agent: comment.user&.ai_user? || false,
333
+ created_at: comment.created_at&.iso8601,
334
+ content: content[start..] || ""
335
+ }
336
+ return message if start.zero?
337
+
338
+ message.merge(
339
+ content_offset: start,
340
+ content_end_offset: content.length,
341
+ content_total_chars: content.length
342
+ )
343
+ end
344
+
345
+ # Image metadata lives inside the pageable content rather than beside it.
346
+ # That keeps image-only comments meaningful while letting max_chars and
347
+ # content_offset bound and continue an arbitrarily large attachment list
348
+ # through the same contract as the message prose.
349
+ def serialized_content(comment)
350
+ text = Collavre::HtmlText.plain(comment.content).strip
351
+ [ text.presence, image_markers(comment).presence ].compact.join("\n\n")
352
+ end
353
+
354
+ def image_markers(comment)
355
+ comment.images.map.with_index(1) do |image, index|
356
+ "[Image attachment #{index}: #{image.filename.sanitized} " \
357
+ "(#{image.content_type}, #{image.byte_size} bytes) — #{public_asset_url(image.blob)}]"
358
+ end.join("\n")
359
+ end
360
+
361
+ def budget_exhausted?(messages, next_cursor)
362
+ return false if @budget.unlimited?
363
+
364
+ messages.any? { |message| message[:clipped] } ||
365
+ (messages.size < @limit && next_cursor.present?)
366
+ end
367
+
368
+ def clamp_limit(limit)
369
+ limit = limit.to_i
370
+ return DEFAULT_LIMIT if limit <= 0
371
+
372
+ [ limit, MAX_LIMIT ].min
373
+ end
374
+
375
+ def normalized_order(order)
376
+ ORDERS.include?(order.to_s) ? order.to_s : DEFAULT_ORDER
377
+ end
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # Renders the topic_messages payload as markdown.
6
+ #
7
+ # Renders the *payload*, not the records, so the markdown and json formats
8
+ # of the same call cannot disagree about what was returned — the json branch
9
+ # returns the identical hash this reads.
10
+ #
11
+ # Markdown is the default format because the content is prose. Wrapping
12
+ # conversation in JSON escaping costs a large fraction of the character
13
+ # budget the paging exists to protect, and buys nothing: the caller is going
14
+ # to read the words, not index into them.
15
+ module MessagePageMarkdown
16
+ module_function
17
+
18
+ def call(payload)
19
+ sections = payload[:topics].map { |topic| render_topic(topic) }
20
+ sections << truncation_notice if payload[:truncated]
21
+ sections.join("\n")
22
+ end
23
+
24
+ def render_topic(entry)
25
+ return render_error(entry) if entry[:error]
26
+ return "#{title(entry)}\n#{skipped_notice(entry)}\n" if entry[:skipped_reason]
27
+
28
+ [ header(entry), *continuation(entry), "", *body(entry) ].join("\n")
29
+ end
30
+
31
+ def render_error(entry)
32
+ "## [topic #{entry[:topic_id]}] unavailable\n#{entry[:error]}\n"
33
+ end
34
+
35
+ def title(entry)
36
+ "## [topic #{entry[:topic_id]}] #{entry[:topic_name]} (creative #{entry[:creative_id]})"
37
+ end
38
+
39
+ def header(entry)
40
+ "#{title(entry)}\n" \
41
+ "#{entry[:total_count]} messages, ~#{entry[:total_chars]} raw chars — " \
42
+ "showing #{shown_range(entry)} of #{entry[:total_count]}, newest-first window"
43
+ end
44
+
45
+ def shown_range(entry)
46
+ return "none" if entry[:returned_count].to_i.zero?
47
+
48
+ "#{entry[:offset]}-#{entry[:offset] + entry[:returned_count] - 1}"
49
+ end
50
+
51
+ # The next call is spelled out rather than left as a next_offset field to
52
+ # decode. max_message_id is repeated into it because that is the whole
53
+ # point of the anchor — a caller that drops it silently re-reads messages
54
+ # as the topic grows underneath the pagination.
55
+ #
56
+ # include_system is repeated for the same reason, and it matters more:
57
+ # the anchor shifts rows, but a narrower scope renumbers them. Paging
58
+ # into a set that no longer contains the system rows the first page
59
+ # counted lands the offset past messages that were never returned.
60
+ def continuation(entry)
61
+ return [] unless entry[:has_more]
62
+
63
+ [ "More: topic_messages(topic_ids: #{entry[:topic_id]}, offset: #{entry[:next_offset]}, " \
64
+ "cursor: \"#{entry[:next_cursor]}\", " \
65
+ "max_message_id: #{entry[:newest_message_id]}, limit: #{entry[:limit]}, " \
66
+ "max_chars: #{entry[:max_chars]}#{content_option(entry)}" \
67
+ "#{order_option(entry)}#{scope_option(entry)})" ]
68
+ end
69
+
70
+ def content_option(entry)
71
+ entry[:next_content_offset] ? ", content_offset: #{entry[:next_content_offset]}" : ""
72
+ end
73
+
74
+ def order_option(entry)
75
+ entry[:order] == "desc" ? ', order: "desc"' : ""
76
+ end
77
+
78
+ def scope_option(entry)
79
+ entry[:include_system] ? ", include_system: true" : ""
80
+ end
81
+
82
+ def skipped_notice(entry)
83
+ "Not fetched: #{entry[:skipped_reason]}. Re-request this topic alone, or raise max_chars."
84
+ end
85
+
86
+ # Oldest-to-newest within the window by default, so the transcript reads
87
+ # forwards even though the window was chosen from the newest end.
88
+ def body(entry)
89
+ entry[:messages].map do |message|
90
+ rendered = "[#{message[:id]}] #{message[:created_at]} #{message[:author]}" \
91
+ "#{message[:agent] ? ' (agent)' : ''}\n#{message[:content]}\n"
92
+ message[:clip_notice] ? "#{rendered}#{message[:clip_notice]}\n" : rendered
93
+ end
94
+ end
95
+
96
+ def truncation_notice
97
+ "> Output hit max_chars. Topics above may be partial — see each topic's " \
98
+ "\"More:\" line, or request fewer topics per call.\n"
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # The one definition of "the messages of a topic" that the read tools share,
6
+ # so a count reported by topic_list and a page returned by topic_messages
7
+ # can never describe different sets.
8
+ module MessageScope
9
+ module_function
10
+
11
+ # include_system: false is the default because the caller is almost always
12
+ # summarizing a conversation, and authorless rows are the timeline's
13
+ # furniture — "⏳" concurrency notices, channel announcements. Leaving
14
+ # them in would spend the caller's character budget on text nobody wrote.
15
+ #
16
+ # without_approval_action is NOT part of that switch. Approval-surface
17
+ # rows are excluded unconditionally, because Comment.without_approval_action
18
+ # carries an invariant these tools do not get to opt out of: an approval
19
+ # prompt must never reach an agent as history or trigger context. Every
20
+ # other agent-context query in the engine applies it the same way, and a
21
+ # read tool is exactly the back door that invariant exists to close.
22
+ #
23
+ # visible_to is applied unconditionally for the same reason: a private
24
+ # comment belongs to its author and approver, and a tool must not be the
25
+ # way around that.
26
+ #
27
+ # max_message_id excludes rows created after the first page. The topic
28
+ # assignment anchor excludes older rows moved in after that page, while
29
+ # MessagePage's keyset cursor lets rows leave without shifting unread
30
+ # survivors ahead of a numeric offset.
31
+ def for(topic, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
32
+ for_topics(
33
+ [ topic ], user: user, include_system: include_system,
34
+ max_message_id: max_message_id, topic_assigned_before: topic_assigned_before
35
+ )
36
+ end
37
+
38
+ # Pair every topic id with the creative that was authorized. TopicMove
39
+ # preserves the topic id while relocating all of its comments, so an
40
+ # id-only query could read the destination creative after the permission
41
+ # check. The exact pair also avoids cross-topic matches in batch totals.
42
+ def for_topics(topics, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
43
+ membership = topic_membership(topics)
44
+ return Comment.none unless membership
45
+
46
+ scope = Comment.where(membership).visible_to(user).without_approval_action
47
+ scope = scope.where.not(user_id: nil) unless include_system
48
+ scope = scope.where("comments.id <= ?", max_message_id) if max_message_id
49
+ scope = scope.where("comments.topic_assigned_at <= ?", topic_assigned_before) if topic_assigned_before
50
+ scope
51
+ end
52
+
53
+ def topic_membership(topics)
54
+ table = Comment.arel_table
55
+ Array(topics).map do |topic|
56
+ table[:topic_id].eq(topic.id).and(table[:creative_id].eq(topic.creative_id))
57
+ end.reduce { |combined, pair| combined.or(pair) }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ # Per-topic message totals for a set of topics, in grouped aggregate queries.
6
+ #
7
+ # This is the call that makes fan-out planning possible: before an agent
8
+ # spends its context on message bodies it asks how much there is, and
9
+ # decides how to chunk. Counting by loading is exactly what that is meant to
10
+ # avoid, so nothing here instantiates a Comment.
11
+ module MessageStats
12
+ Stat = Struct.new(:count, :chars, :last_at, keyword_init: true)
13
+
14
+ # Marker prose, attachment index, byte-size digits, separators and the
15
+ # signed-id path are bounded conservatively here. Filename is charged
16
+ # twice because it appears in both the label and URL; content type and a
17
+ # configured public-assets host are charged at their actual lengths.
18
+ ATTACHMENT_MARKER_OVERHEAD_CHARS = 300
19
+
20
+ EMPTY = Stat.new(count: 0, chars: 0, last_at: nil).freeze
21
+
22
+ module_function
23
+
24
+ # Returns { topic_id => Stat }, with EMPTY for topics that have no
25
+ # visible messages (a grouped query returns no row for them at all).
26
+ #
27
+ # `chars` combines LENGTH over the stored HTML with a conservative image
28
+ # marker estimate. It remains a planning estimate rather than an exact
29
+ # rendered size: exact text requires stripping every body, which is the
30
+ # load this query exists to avoid.
31
+ # max_message_id must reach the totals as well as the window. has_more? is
32
+ # (offset + returned) < total, so a total counting past the anchor would
33
+ # keep claiming there is another page after the snapshot has been fully
34
+ # read — the caller pages forever against rows its anchor excludes.
35
+ def for(topics, user:, include_system: false, max_message_id: nil, topic_assigned_before: nil)
36
+ topics = Array(topics)
37
+ return {} if topics.empty?
38
+
39
+ scope = MessageScope.for_topics(
40
+ topics, user: user, include_system: include_system,
41
+ max_message_id: max_message_id, topic_assigned_before: topic_assigned_before
42
+ )
43
+ rows = grouped_rows(scope)
44
+ attachment_chars = attachment_chars_by_topic(scope)
45
+ totals = rows.to_h do |topic_id, count, chars, last_at|
46
+ total_chars = chars.to_i + attachment_chars.fetch(topic_id, 0)
47
+ [ topic_id, Stat.new(count: count.to_i, chars: total_chars, last_at: as_time(last_at)) ]
48
+ end
49
+
50
+ topics.to_h { |topic| [ topic.id, totals[topic.id] || EMPTY ] }
51
+ end
52
+
53
+ # An aggregate selected through Arel.sql carries no column type, so the
54
+ # adapter hands MAX(created_at) back as the raw driver value — a String on
55
+ # Postgres. Callers expect a Time they can format, so normalize here
56
+ # rather than at each of them.
57
+ def as_time(value)
58
+ return value if value.nil? || value.is_a?(Time)
59
+
60
+ Time.zone.parse(value.to_s)
61
+ end
62
+
63
+ def grouped_rows(scope)
64
+ scope.group(:topic_id).pluck(
65
+ :topic_id,
66
+ Arel.sql("COUNT(*)"),
67
+ Arel.sql("COALESCE(SUM(LENGTH(comments.content)), 0)"),
68
+ Arel.sql("MAX(comments.created_at)")
69
+ )
70
+ end
71
+
72
+ def attachment_chars_by_topic(scope)
73
+ host_chars = Collavre::IntegrationSettings.fetch(:public_assets_host).to_s.length
74
+
75
+ attachment_rows(scope).to_h do |topic_id, count, filename_chars, content_type_chars|
76
+ estimate = count.to_i * (ATTACHMENT_MARKER_OVERHEAD_CHARS + host_chars) +
77
+ filename_chars.to_i * 2 + content_type_chars.to_i
78
+ [ topic_id, estimate ]
79
+ end
80
+ end
81
+
82
+ def attachment_rows(scope)
83
+ scope.joins(images_attachments: :blob).group(:topic_id).pluck(
84
+ :topic_id,
85
+ Arel.sql("COUNT(active_storage_attachments.id)"),
86
+ Arel.sql("COALESCE(SUM(LENGTH(active_storage_blobs.filename)), 0)"),
87
+ Arel.sql("COALESCE(SUM(LENGTH(active_storage_blobs.content_type)), 0)")
88
+ )
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Topics
5
+ class MoveBlocker
6
+ class MoveBlockedError < StandardError; end
7
+ class ActiveTaskError < MoveBlockedError; end
8
+ class PendingDeliveryError < MoveBlockedError; end
9
+ class RecurringTaskError < MoveBlockedError; end
10
+ class TriggerLoopError < MoveBlockedError; end
11
+
12
+ def initialize(topic)
13
+ @topic = topic
14
+ end
15
+
16
+ def call
17
+ if Task.where(topic_id: topic.id, status: Task::ACTIVE_STATUSES).exists?
18
+ raise ActiveTaskError, I18n.t("collavre.topics.move.active_tasks")
19
+ end
20
+ if pending_delivery_restoration?
21
+ raise PendingDeliveryError, I18n.t("collavre.topics.move.pending_deliveries")
22
+ end
23
+ if trigger_loop_topic?
24
+ raise TriggerLoopError, I18n.t("collavre.topics.move.trigger_loop")
25
+ end
26
+ return unless Crons::RecurringTopicTasks.new(topic.id).any?
27
+
28
+ raise RecurringTaskError, I18n.t("collavre.topics.move.recurring_tasks")
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :topic
34
+
35
+ def pending_delivery_restoration?
36
+ Task.where(topic_id: topic.id).where.not(status: Task::ACTIVE_STATUSES).find_each.any? do |task|
37
+ Orchestration::DeliveryRecord.pending_restoration?(task)
38
+ end
39
+ end
40
+
41
+ def trigger_loop_topic?
42
+ creative_data = Creative.where(id: topic.creative_id).pick(:data)
43
+ creative_data&.dig("trigger", "loop", "trigger_topic_id").to_i == topic.id
44
+ end
45
+ end
46
+ end
47
+ end