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
@@ -21,7 +21,7 @@ module Collavre
21
21
 
22
22
  attr_reader :comment, :user, :creative, :url_helpers
23
23
 
24
- # /topic "topic name" @agent_name
24
+ # /topic "topic name" @agent_name:
25
25
  # /topic "topic name"
26
26
  COMMAND_PATTERN = /\A\/topic\b/i.freeze
27
27
 
@@ -53,12 +53,61 @@ module Collavre
53
53
  # Find existing topic or create new one
54
54
  existing_topic = Topic.find_by(creative: creative, name: data[:name])
55
55
 
56
+ if existing_topic&.session_id.present?
57
+ # A Claude Channel session topic carries its agent as session identity,
58
+ # not as a routing pin (see TopicsController#set_primary_agent). Neither
59
+ # reassigning nor releasing it is safe from a chat command.
60
+ return I18n.t("collavre.comments.topic_command.session_topic_locked",
61
+ name: existing_topic.name)
62
+ end
63
+
64
+ # User.mentionable_for resolves every searchable agent — and, on an
65
+ # inbox, every agent holding a share there, which includes the user's own
66
+ # Claude Channel session agent. Pinning an agent Matcher will not route
67
+ # to silences the topic outright: the pin is exclusive
68
+ # (Matcher#match_by_primary_agent suppresses the other agents' ambient
69
+ # routing) while the pinned agent itself is filtered out and cannot
70
+ # answer either. Resolved against the topic being targeted, so the check
71
+ # is the same mirror of Matcher the REST paths apply.
72
+ rejection = primary_agent &&
73
+ Topic.primary_agent_rejection(creative, primary_agent, topic: existing_topic)
74
+ if rejection
75
+ key = if rejection == :session_agent_outside_session_topic
76
+ "collavre.comments.topic_command.session_agent_not_assignable"
77
+ else
78
+ "collavre.comments.topic_command.agent_no_creative_access"
79
+ end
80
+ return I18n.t(key, agent: primary_agent.display_name)
81
+ end
82
+
83
+ # Assigning or releasing a primary agent rewrites the topic's routing:
84
+ # the pin decides who may speak here and silences every other agent. The
85
+ # REST equivalents (TopicsController#set_primary_agent and #create, which
86
+ # also accepts agent_id) require :write, but CommentsController#create
87
+ # only authorizes the comment at :feedback, so the command has to apply
88
+ # the same gate itself — otherwise commenting access would be enough to
89
+ # decide a topic's routing. Checked before the new/existing split because
90
+ # `/topic "new name" @agent:` persists an exclusive assignment just as
91
+ # much as moving one on a topic that already exists.
92
+ if assigns_primary_agent?(existing_topic, primary_agent) &&
93
+ !creative.has_permission?(user, :write)
94
+ return I18n.t("collavre.comments.topic_command.not_authorized")
95
+ end
96
+
56
97
  if existing_topic
57
98
  if primary_agent
58
99
  set_primary_agent(existing_topic, primary_agent)
100
+ broadcast_topic_agent_updated(existing_topic, primary_agent)
59
101
  I18n.t("collavre.comments.topic_command.updated_agent",
60
102
  name: existing_topic.name,
61
103
  agent: primary_agent.name)
104
+ elsif existing_topic.primary_agent_id
105
+ # /topic "name" with no @mention on an already-assigned topic releases
106
+ # the assignment — the keyboard counterpart of clicking the avatar off.
107
+ set_primary_agent(existing_topic, nil)
108
+ broadcast_topic_agent_updated(existing_topic, nil)
109
+ I18n.t("collavre.comments.topic_command.cleared_agent",
110
+ name: existing_topic.name)
62
111
  else
63
112
  I18n.t("collavre.comments.topic_command.already_exists",
64
113
  name: existing_topic.name)
@@ -95,6 +144,17 @@ module Collavre
95
144
  TopicsChannel.broadcast_to(creative, data)
96
145
  end
97
146
 
147
+ # Push an assignment change on an existing topic to every connected client.
148
+ # :primary_agent is always present (nil when released) so the merge on the
149
+ # client can actually remove the avatar rather than keep a stale one.
150
+ def broadcast_topic_agent_updated(topic, agent)
151
+ data = { action: "updated", topic: topic.slice(:id, :name), user_id: user.id }
152
+ data[:topic][:primary_agent] = if agent
153
+ { id: agent.id, name: agent.display_name, avatar_url: resolve_avatar_url(agent) }
154
+ end
155
+ TopicsChannel.broadcast_to(creative, data)
156
+ end
157
+
98
158
  def resolve_avatar_url(agent)
99
159
  if agent.avatar.attached?
100
160
  Rails.application.routes.url_helpers.rails_blob_url(
@@ -110,6 +170,17 @@ module Collavre
110
170
  def set_primary_agent(topic, agent)
111
171
  topic.set_primary_agent!(agent)
112
172
  end
173
+
174
+ # True when the command would write primary_agent_id: a mention assigns
175
+ # (or moves) the pin on either a new or an existing topic, and a bare
176
+ # /topic on an already assigned topic releases it. `/topic "name"` with no
177
+ # mention writes no assignment — on an unassigned existing topic it only
178
+ # reports that it already exists, and on a new one it creates a topic with
179
+ # ambient routing intact — so both stay open to commenters, matching the
180
+ # pre-existing rule that /topic can create topics at :feedback.
181
+ def assigns_primary_agent?(topic, primary_agent)
182
+ primary_agent.present? || topic&.primary_agent_id.present?
183
+ end
113
184
  end
114
185
  end
115
186
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module Comments
5
+ module TopicMutation
6
+ module_function
7
+
8
+ def call(topic_id, creative_id)
9
+ applied = false
10
+ ActiveRecord::Base.transaction do
11
+ next unless Orchestration::TopicSlot.lock_matches_context?(topic_id, creative_id)
12
+
13
+ yield
14
+ applied = true
15
+ end
16
+ applied
17
+ end
18
+ end
19
+ end
20
+ end
@@ -84,7 +84,7 @@ module Creatives
84
84
  end
85
85
 
86
86
  def ancestor_label(creative)
87
- creative.effective_description(nil, false).to_s.gsub(/\s+/, " ").strip
87
+ Collavre::HtmlText.label(creative.effective_description(nil, true))
88
88
  end
89
89
  end
90
90
  end
@@ -1,30 +1,66 @@
1
1
  module Collavre
2
2
  module Creatives
3
- # Batches the browse tree's comment badge. Per node this was two queries — a
4
- # read-pointer lookup and an unread COUNT — which is what made the badge cost
5
- # scale with the size of the rendered tree.
3
+ # Resolves the unread and visible comment counts used by every comment badge.
4
+ # Per node this was two queries — a read-pointer lookup and an unread COUNT —
5
+ # which is what made the browse-tree badge cost scale with the rendered tree.
6
6
  #
7
7
  # Keyed by *origin*: a linked shell shows its origin's comments.
8
+ #
9
+ # The counts it hands out are display-ready: they include only comments the
10
+ # user can see and apply presence suppression in one cache round trip.
8
11
  class CommentBadgeIndex
12
+ # SQLite permits at most 1,000 expression nodes and commonly only 999 bound
13
+ # values. Keep the largest badge query well below both limits.
14
+ QUERY_BATCH_SIZE = 500
15
+
16
+ Badge = Struct.new(:unread_count, :visible_comments, keyword_init: true)
17
+
18
+ # The comment-created fanout has one origin and many recipients. Resolve
19
+ # each topic watermark with database COUNTs instead of materialising the
20
+ # comment history in Ruby. Joining every recipient to public history made
21
+ # one broadcast materialise recipients × comments rows before grouping.
22
+ def self.for_users(origin:, users:)
23
+ recipients = users.compact.uniq(&:id)
24
+ return {} if recipients.empty?
25
+
26
+ counts_by_user_id = counts_for_users(origin, recipients.map(&:id))
27
+ present_user_ids = CommentPresenceStore.list(origin.id)
28
+ viewing_topics_by_user_id = CommentPresenceStore.viewing_topics_for(origin.id, present_user_ids)
29
+ recipients.to_h do |user|
30
+ counts_by_topic = counts_by_user_id.fetch(user.id, {})
31
+ viewing_topics = viewing_topics_by_user_id.fetch(user.id, [])
32
+ unread_count = counts_by_topic.sum do |topic_id, counts|
33
+ topic_is_viewed?(topic_id, viewing_topics) ? 0 : counts.fetch(:unread)
34
+ end
35
+
36
+ [ user.id, Badge.new(
37
+ unread_count: unread_count,
38
+ visible_comments: counts_by_topic.values.sum { |counts| counts.fetch(:visible) }.positive?
39
+ ) ]
40
+ end
41
+ end
42
+
9
43
  def initialize(user:)
10
44
  @user = user
11
45
  @unread_by_origin_id = {}
46
+ @visible_by_origin_id = {}
12
47
  end
13
48
 
14
- def index(origins)
49
+ # Tree rendering only needs unread totals. Callers that render a standalone
50
+ # badge also need visible-comment state to decide whether a zero is shown.
51
+ def index(origins, include_visible_counts: true)
15
52
  pending = origins.uniq(&:id).reject { |o| @unread_by_origin_id.key?(o.id) }
16
53
  return if pending.empty?
17
54
 
18
- watermarks = read_watermarks(pending.map(&:id))
19
-
20
- # No read pointer means nothing has been read yet, so every comment counts
21
- # as unread — including private ones, matching the counter cache.
22
- pending.each do |origin|
23
- @unread_by_origin_id[origin.id] = origin.comments_count unless watermarks.key?(origin.id)
55
+ origin_ids = pending.map(&:id)
56
+ watermarks = read_watermarks(origin_ids)
57
+ unread_counts = unread_counts(origin_ids, watermarks)
58
+ origin_ids.each do |origin_id|
59
+ @unread_by_origin_id[origin_id] = unread_counts.fetch(origin_id, 0)
24
60
  end
61
+ @visible_by_origin_id.merge!(visible_counts(origin_ids)) if include_visible_counts
25
62
 
26
- counts = unread_counts(watermarks)
27
- watermarks.each_key { |origin_id| @unread_by_origin_id[origin_id] = counts.fetch(origin_id, 0) }
63
+ suppress_for_present_user(pending.map(&:id))
28
64
  end
29
65
 
30
66
  # nil when the origin was never indexed, which tells the caller to fall back
@@ -33,40 +69,276 @@ module Creatives
33
69
  @unread_by_origin_id[origin.id]
34
70
  end
35
71
 
72
+ def visible_comments?(origin)
73
+ @visible_by_origin_id[origin.id].to_i.positive?
74
+ end
75
+
76
+ # The topic strip needs the same display-ready unread state as the creative
77
+ # badge, split by topic. A topic watermark wins; a legacy creative-wide
78
+ # watermark is only the fallback for a topic without its own row.
79
+ def unread_counts_by_topic(origin)
80
+ counts = unread_counts_by_topic_without_presence(origin)
81
+ suppress_viewing_topics!(counts, origin) if user
82
+ counts
83
+ end
84
+
36
85
  private
37
86
 
38
87
  attr_reader :user
39
88
 
89
+ # One read_multi for the whole level. An anonymous visitor is never present.
90
+ def suppress_for_present_user(origin_ids)
91
+ return unless user
92
+
93
+ present_origin_ids = CommentPresenceStore.list_many(origin_ids).filter_map do |origin_id, present_user_ids|
94
+ origin_id if present_user_ids.include?(user.id)
95
+ end
96
+ viewing_topics_by_origin_id = CommentPresenceStore.viewing_topics_for_creatives(user.id, present_origin_ids)
97
+
98
+ present_origin_ids.each do |origin_id|
99
+ origin = Creative.find_by(id: origin_id)
100
+ next unless origin
101
+
102
+ all_counts = unread_counts_by_topic_without_presence(origin)
103
+ remaining_counts = all_counts.dup
104
+ suppress_viewing_topics!(remaining_counts, origin, viewing_topics: viewing_topics_by_origin_id.fetch(origin_id, []))
105
+ @unread_by_origin_id[origin_id] -= all_counts.values.sum - remaining_counts.values.sum
106
+ end
107
+ end
108
+
109
+ def suppress_viewing_topics!(counts, origin, viewing_topics: CommentPresenceStore.viewing_topics(origin.id, user.id))
110
+ if viewing_topics.include?(CommentPresenceStore::ALL_TOPICS)
111
+ # All Messages renders Main and the active-topic snapshot returned with
112
+ # its list. A topic restored after that response stays unread until the
113
+ # list is reloaded and reports it in a new snapshot.
114
+ counts.delete(nil) if viewing_topics.include?(CommentPresenceStore::LEGACY_TOPIC)
115
+ end
116
+
117
+ # Another subscription can be viewing an archived topic while this one is
118
+ # on All Messages. Suppress every explicitly rendered topic as well.
119
+ viewing_topics.excluding(CommentPresenceStore::ALL_TOPICS).each { |topic_id| counts.delete(topic_id) }
120
+ end
121
+
40
122
  # `user_id: nil` for an anonymous visitor is the lookup the un-batched path
41
123
  # made too, so the two agree on which pointer (if any) applies.
42
124
  def read_watermarks(origin_ids)
43
125
  return {} if origin_ids.empty?
44
126
 
45
- CommentReadPointer
46
- .where(user_id: user&.id, creative_id: origin_ids)
47
- .where.not(last_read_comment_id: nil)
48
- .pluck(:creative_id, :last_read_comment_id)
49
- .to_h
127
+ origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, watermarks|
128
+ watermarks.merge!(CommentReadPointer
129
+ .where(user_id: user&.id, creative_id: origin_id_batch)
130
+ .pluck(:creative_id, :topic_id, :last_read_comment_id)
131
+ .each_with_object({}) do |(creative_id, topic_id, last_read_id), rows|
132
+ (rows[creative_id] ||= {})[topic_id] = last_read_id || 0
133
+ end)
134
+ end
50
135
  end
51
136
 
52
137
  # One grouped COUNT for the whole level. Each origin carries its own read
53
138
  # watermark, so the thresholds are OR-ed together rather than shared. Built
54
139
  # from relations rather than a SQL fragment: a hand-built string here would
55
140
  # be safe (literal template, bound values) but unprovably so to a scanner.
56
- def unread_counts(watermarks)
57
- return {} if watermarks.empty?
141
+ def unread_counts(origin_ids, watermarks)
142
+ origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, counts|
143
+ counts.merge!(unread_counts_for_batch(origin_id_batch, watermarks))
144
+ end
145
+ end
146
+
147
+ def unread_counts_for_batch(origin_ids, watermarks)
148
+ scopes = origin_ids.map { |origin_id| unread_scope(origin_id, watermarks.fetch(origin_id, {})) }
149
+ visible_comments.merge(scopes.reduce { |combined, scope| combined.or(scope) }).group(:creative_id).count
150
+ end
151
+
152
+ def unread_scope(origin_id, watermarks)
153
+ legacy_watermark = watermarks.fetch(nil, 0)
154
+ topic_watermarks = watermarks.except(nil)
155
+ return Comment.where(creative_id: origin_id).where(Comment.arel_table[:id].gt(legacy_watermark)) if topic_watermarks.empty?
156
+
157
+ comment_table = Comment.arel_table
158
+ topic_ids = topic_watermarks.keys
159
+ fallback_scope = Comment.where(creative_id: origin_id).where(comment_table[:id].gt(legacy_watermark))
160
+ fallback_scope = fallback_scope.where(topic_id: nil).or(fallback_scope.where.not(topic_id: topic_ids))
161
+ topic_scopes = topic_watermarks.map do |topic_id, last_read_id|
162
+ Comment.where(creative_id: origin_id, topic_id: topic_id).where(comment_table[:id].gt(last_read_id))
163
+ end
164
+
165
+ ([ fallback_scope ] + topic_scopes).reduce { |combined, scope| combined.or(scope) }
166
+ end
167
+
168
+ def unread_counts_by_topic_without_presence(origin)
169
+ watermarks = read_watermarks([ origin.id ]).fetch(origin.id, {})
170
+ visible_comments.merge(unread_scope(origin.id, watermarks)).group(:topic_id).count
171
+ end
58
172
 
59
- newer_than_watermark = watermarks
60
- .map { |origin_id, last_read_id| unread_scope(origin_id, last_read_id) }
61
- .reduce { |combined, scope| combined.or(scope) }
173
+ def visible_counts(origin_ids)
174
+ origin_ids.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |origin_id_batch, counts|
175
+ counts.merge!(visible_comments.where(creative_id: origin_id_batch).group(:creative_id).count)
176
+ end
177
+ end
62
178
 
63
- Comment.where(private: false).merge(newer_than_watermark).group(:creative_id).count
179
+ def visible_comments
180
+ user ? Comment.visible_to(user) : Comment.public_only
64
181
  end
65
182
 
66
- def unread_scope(origin_id, last_read_id)
67
- Comment
68
- .where(creative_id: origin_id)
69
- .where(Comment.arel_table[:id].gt(last_read_id))
183
+ class << self
184
+ private
185
+
186
+ # A comment broadcast can fan out to many people. Count only the suffix
187
+ # after each effective watermark in the database; loading all historical
188
+ # comment IDs for every broadcast made the cost grow with the full thread.
189
+ def counts_for_users(origin, user_ids)
190
+ watermarks_by_user_id = read_watermarks_for_users(origin, user_ids)
191
+ public_topic_ids = Comment.where(creative_id: origin.id, private: false).distinct.pluck(:topic_id)
192
+ private_topic_ids_by_user_id = private_topic_ids_for_users(origin, user_ids)
193
+ requests_by_user_id = requests_by_user(user_ids, public_topic_ids, private_topic_ids_by_user_id, watermarks_by_user_id)
194
+ public_unread_counts = public_unread_counts_for(origin, requests_by_user_id.values)
195
+ private_requests_by_user_id = private_requests_by_user(user_ids, private_topic_ids_by_user_id, watermarks_by_user_id)
196
+ private_counts = private_counts_for(origin, private_requests_by_user_id)
197
+
198
+ counts_by_user(user_ids, requests_by_user_id, public_unread_counts, private_counts, public_topic_ids.any?)
199
+ end
200
+
201
+ def requests_by_user(user_ids, public_topic_ids, private_topics_by_user, watermarks_by_user)
202
+ user_ids.to_h do |user_id|
203
+ topic_ids = public_topic_ids | private_topics_by_user.fetch(user_id, [])
204
+ [ user_id, requests_for_topics(topic_ids, watermarks_by_user[user_id]) ]
205
+ end
206
+ end
207
+
208
+ def private_requests_by_user(user_ids, private_topics_by_user, watermarks_by_user)
209
+ user_ids.to_h do |user_id|
210
+ [ user_id, requests_for_topics(private_topics_by_user.fetch(user_id, []), watermarks_by_user[user_id]) ]
211
+ end
212
+ end
213
+
214
+ def requests_for_topics(topic_ids, watermarks)
215
+ topic_ids.to_h { |topic_id| [ topic_id, watermark_for(watermarks, topic_id) ] }
216
+ end
217
+
218
+ def counts_by_user(user_ids, requests_by_user, public_counts, private_counts, public_comments_exist)
219
+ user_ids.to_h do |user_id|
220
+ [ user_id, counts_by_topic(user_id, requests_by_user.fetch(user_id), public_counts, private_counts, public_comments_exist) ]
221
+ end
222
+ end
223
+
224
+ def counts_by_topic(user_id, requests, public_counts, private_counts, public_comments_exist)
225
+ requests.to_h do |topic_id, watermark|
226
+ private_count = private_counts.fetch([ user_id, topic_id, watermark ], { unread: 0, visible: 0 })
227
+ [ topic_id, combined_counts(topic_id, watermark, public_counts, private_count, public_comments_exist) ]
228
+ end
229
+ end
230
+
231
+ def combined_counts(topic_id, watermark, public_counts, private_count, public_comments_exist)
232
+ {
233
+ unread: public_counts.fetch([ topic_id, watermark ], 0) + private_count.fetch(:unread),
234
+ visible: (public_comments_exist || private_count.fetch(:visible).positive?) ? 1 : 0
235
+ }
236
+ end
237
+
238
+ def private_topic_ids_for_users(origin, user_ids)
239
+ topic_ids_by_user_id = Hash.new { |hash, user_id| hash[user_id] = [] }
240
+ [ :user_id, :approver_id ].each do |recipient_column|
241
+ Comment.where(creative_id: origin.id, private: true, recipient_column => user_ids).distinct.pluck(recipient_column, :topic_id).each do |user_id, topic_id|
242
+ topic_ids_by_user_id[user_id] << topic_id
243
+ end
244
+ end
245
+ topic_ids_by_user_id.transform_values!(&:uniq)
246
+ end
247
+
248
+ def public_unread_counts_for(origin, requests_by_user_id)
249
+ requests = requests_by_user_id.flat_map(&:to_a).uniq
250
+ return {} if requests.empty?
251
+
252
+ zero_watermark_topic_ids = requests.select { |_, watermark| watermark.zero? }.map(&:first)
253
+ counts = public_counts_after(origin, zero_watermark_topic_ids.uniq, 0)
254
+ positive_requests = requests.reject { |_, watermark| watermark.zero? }
255
+ positive_requests.each_slice(QUERY_BATCH_SIZE) do |request_batch|
256
+ counts.merge!(counts_from_rows(public_count_rows(origin, request_batch)) do |row|
257
+ [ row.fetch("topic_id")&.to_i, row.fetch("watermark").to_i ]
258
+ end)
259
+ end
260
+ counts
261
+ end
262
+
263
+ def public_counts_after(origin, topic_ids, watermark)
264
+ return {} if topic_ids.empty?
265
+
266
+ Comment.where(creative_id: origin.id, private: false, topic_id: topic_ids)
267
+ .where(Comment.arel_table[:id].gt(watermark))
268
+ .group(:topic_id)
269
+ .count
270
+ .transform_keys { |topic_id| [ topic_id, watermark ] }
271
+ end
272
+
273
+ def private_counts_for(origin, requests_by_user_id)
274
+ requests = requests_by_user_id.flat_map do |user_id, requests_by_topic|
275
+ requests_by_topic.map { |topic_id, watermark| [ user_id, topic_id, watermark ] }
276
+ end
277
+ requests.each_slice(QUERY_BATCH_SIZE).each_with_object({}) do |request_batch, counts|
278
+ counts.merge!(private_count_rows(origin, request_batch).to_h do |row|
279
+ [ [ row.fetch("user_id").to_i, row.fetch("topic_id")&.to_i, row.fetch("watermark").to_i ], {
280
+ unread: row.fetch("unread_count").to_i,
281
+ visible: row.fetch("visible_count").to_i
282
+ } ]
283
+ end)
284
+ end
285
+ end
286
+
287
+ def public_count_rows(origin, requests)
288
+ count_rows(requests) do |topic_id, watermark|
289
+ [
290
+ "SELECT ? AS topic_id, ? AS watermark, COUNT(*) AS unread_count FROM comments " \
291
+ "WHERE creative_id = ? AND private = ? AND #{topic_condition(topic_id)} AND id > ?",
292
+ topic_id, watermark, origin.id, false, *topic_binds(topic_id), watermark
293
+ ]
294
+ end
295
+ end
296
+
297
+ def private_count_rows(origin, requests)
298
+ count_rows(requests) do |user_id, topic_id, watermark|
299
+ [
300
+ "SELECT ? AS user_id, ? AS topic_id, ? AS watermark, " \
301
+ "SUM(CASE WHEN id > ? THEN 1 ELSE 0 END) AS unread_count, COUNT(*) AS visible_count FROM comments " \
302
+ "WHERE creative_id = ? AND private = ? AND #{topic_condition(topic_id)} AND (user_id = ? OR approver_id = ?)",
303
+ user_id, topic_id, watermark, watermark, origin.id, true, *topic_binds(topic_id), user_id, user_id
304
+ ]
305
+ end
306
+ end
307
+
308
+ def count_rows(requests)
309
+ sql = requests.map { |request| Comment.sanitize_sql_array(yield(*request)) }.join(" UNION ALL ")
310
+ Comment.connection.select_all(sql).to_a
311
+ end
312
+
313
+ def counts_from_rows(rows)
314
+ rows.to_h { |row| [ yield(row), row.fetch("unread_count").to_i ] }
315
+ end
316
+
317
+ def topic_condition(topic_id)
318
+ topic_id.nil? ? "topic_id IS NULL" : "topic_id = ?"
319
+ end
320
+
321
+ def topic_binds(topic_id)
322
+ topic_id.nil? ? [] : [ topic_id ]
323
+ end
324
+
325
+ def read_watermarks_for_users(origin, user_ids)
326
+ CommentReadPointer.where(user_id: user_ids, creative_id: origin.id)
327
+ .pluck(:user_id, :topic_id, :last_read_comment_id)
328
+ .each_with_object(Hash.new { |hash, user_id| hash[user_id] = {} }) do |(user_id, topic_id, last_read_id), watermarks|
329
+ watermarks[user_id][topic_id] = last_read_id || 0
330
+ end
331
+ end
332
+
333
+ def watermark_for(watermarks, topic_id)
334
+ watermarks.fetch(topic_id, watermarks.fetch(nil, 0))
335
+ end
336
+
337
+ def topic_is_viewed?(topic_id, viewing_topics)
338
+ return viewing_topics.include?(topic_id) unless viewing_topics.include?(CommentPresenceStore::ALL_TOPICS)
339
+
340
+ topic_id.nil? ? viewing_topics.include?(CommentPresenceStore::LEGACY_TOPIC) : viewing_topics.include?(topic_id)
341
+ end
70
342
  end
71
343
  end
72
344
  end
@@ -26,7 +26,7 @@ module Creatives
26
26
  def reorder_multiple(dragged_ids:, target_id:, direction:)
27
27
  ids = Array(dragged_ids).map(&:presence).compact
28
28
  validate_direction!(direction)
29
- raise Error, "Invalid creatives" if ids.empty?
29
+ raise Error, "Invalid creatives" if ids.empty? || ids.map(&:to_s).uniq.size != ids.size
30
30
 
31
31
  target = Creative.find_by(id: target_id)
32
32
  raise Error, "Invalid creatives" unless target
@@ -178,8 +178,20 @@ module Creatives
178
178
  end
179
179
 
180
180
  def resequence!(creatives)
181
- creatives.each_with_index do |creative, idx|
182
- creative.update_column(:sequence, idx)
181
+ return if creatives.empty?
182
+
183
+ table = Creative.arel_table
184
+ cases = Arel::Nodes::Case.new(table[Creative.primary_key])
185
+ creatives.each_with_index do |creative, index|
186
+ cases.when(creative.id).then(index)
187
+ end
188
+
189
+ Creative.where(id: creatives.map(&:id))
190
+ .update_all(sequence: cases)
191
+
192
+ creatives.each_with_index do |creative, index|
193
+ creative.write_attribute(:sequence, index)
194
+ creative.send(:clear_attribute_change, :sequence)
183
195
  end
184
196
  end
185
197
  end
@@ -97,7 +97,7 @@ module Creatives
97
97
  visible = creatives.reject(&:archived?).select { |creative| can_feedback?(creative) }
98
98
  return if visible.empty?
99
99
 
100
- comment_badge_index.index(visible.map(&:effective_origin))
100
+ comment_badge_index.index(visible.map(&:effective_origin), include_visible_counts: false)
101
101
  end
102
102
 
103
103
  def build_nodes(creatives, level:)
@@ -1,7 +1,7 @@
1
1
  module Collavre
2
2
  module Creatives
3
3
  # Compact markdown tree formatter for AI Agent consumption.
4
- # Used by: creative_retrieval_service, GeminiParentRecommender, Agent context injection.
4
+ # Used by: creative_retrieval_service and agent context injection.
5
5
  #
6
6
  # Output format (header declared once, rows are values only):
7
7
  # <!-- format: [id] description (progress%) -->
@@ -34,10 +34,10 @@ module Collavre
34
34
  lines.join("\n")
35
35
  end
36
36
 
37
- # Extract plain text description from a creative (shared helper)
37
+ # Extract plain text description from a creative (shared helper).
38
+ # One creative must occupy exactly one row, so this collapses newlines.
38
39
  def self.plain_description(creative)
39
- raw = creative.effective_description(nil, true)
40
- ActionView::Base.full_sanitizer.sanitize(raw).strip
40
+ Collavre::HtmlText.label(creative.effective_description(nil, true))
41
41
  end
42
42
 
43
43
  private
@@ -53,7 +53,7 @@ module Collavre
53
53
 
54
54
  if @include_comments
55
55
  node.comments.order(created_at: :desc).limit(3).reverse_each do |comment|
56
- comment_text = ActionView::Base.full_sanitizer.sanitize(comment.content).strip.truncate(100)
56
+ comment_text = Collavre::HtmlText.truncated_label(comment.content, 100)
57
57
  lines << "#{indent} > #{comment_text}"
58
58
  end
59
59
  end
@@ -67,7 +67,6 @@ module Collavre
67
67
  if @use_permissions
68
68
  node.linked_children
69
69
  else
70
- # When children are pre-set on association target (e.g. GeminiParentRecommender)
71
70
  node.children
72
71
  end
73
72
  end
@@ -0,0 +1,50 @@
1
+ module Collavre
2
+ module Creatives
3
+ # Resolves the IDs that the persistent workspace tree can actually render for
4
+ # the current creative. Shared origin paths are re-rooted through the user's
5
+ # linked shell, and unreadable or archived ancestors are omitted.
6
+ class WorkspacePathResolver
7
+ def initialize(creative:, user:)
8
+ @creative = creative
9
+ @user = user
10
+ end
11
+
12
+ def call
13
+ return [] unless creative
14
+
15
+ return renderable_ids(local_path_ids) if creative.origin_id?
16
+
17
+ origin_path = local_path_ids
18
+ reveal = RevealPathResolver.new([ creative.id ], user: user).call[creative.id]
19
+ return renderable_ids(origin_path) if reveal.blank?
20
+
21
+ reveal_paths = origin_path.filter_map { |id| [ id, reveal.fetch(id) ] if reveal.key?(id) }
22
+ renderable_reveal_ids = renderable_ids(reveal_paths.flat_map(&:last).uniq).to_set
23
+ anchor = reveal_paths.reverse.find do |_id, path|
24
+ path.all? { |id| renderable_reveal_ids.include?(id) }
25
+ end
26
+ return renderable_ids(origin_path) unless anchor
27
+
28
+ anchor_id, reveal_path = anchor
29
+ anchor_index = origin_path.index(anchor_id)
30
+ reveal_path + renderable_ids(origin_path.drop(anchor_index + 1))
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :creative, :user
36
+
37
+ def local_path_ids
38
+ creative.ancestors.reverse.pluck(:id) + [ creative.id ]
39
+ end
40
+
41
+ def renderable_ids(ids)
42
+ return [] if ids.empty?
43
+
44
+ readable = PermissionFilter.new(user: user).readable_ids(ids).to_set
45
+ active = Creative.where(id: ids, archived_at: nil).pluck(:id).to_set
46
+ ids.select { |id| readable.include?(id) && active.include?(id) }
47
+ end
48
+ end
49
+ end
50
+ end