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
@@ -11,6 +11,22 @@ module Collavre
11
11
  CANCEL_CHECK_INTERVAL = 1.0
12
12
  # Interval (in seconds) between agent_status heartbeats during streaming
13
13
  AGENT_STATUS_HEARTBEAT_INTERVAL = 3.0
14
+ # Statuses written to the row by another process while this worker is
15
+ # inside the provider call: user Stop -> cancelled, StuckDetectorJob ->
16
+ # failed. Both must stop this worker — a row already settled externally
17
+ # has nobody waiting on this turn, and streaming on holds a worker
18
+ # thread for the rest of the provider call.
19
+ TERMINAL_STATUSES = %w[cancelled failed].freeze
20
+
21
+ def self.topic_id_for(task:, creative:)
22
+ task.topic_id ||
23
+ task.trigger_event_payload&.dig("topic", "id") ||
24
+ Comment.where(
25
+ id: task.trigger_event_payload&.dig("comment", "id"),
26
+ creative_id: creative.id
27
+ ).pick(:topic_id) ||
28
+ creative.main_topic.id
29
+ end
14
30
 
15
31
  def initialize(task:, agent:, creative:)
16
32
  @task = task
@@ -18,6 +34,10 @@ module Collavre
18
34
  @creative = creative
19
35
  @last_cancel_check_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
36
  @last_heartbeat_at = @last_cancel_check_at
37
+ # Captured once so a setting changed mid-turn cannot make the deadline
38
+ # and the message that reports crossing it disagree about what fired.
39
+ @turn_deadline_seconds = SystemSetting.ai_agent_turn_deadline_seconds
40
+ @deadline_at = @last_cancel_check_at + @turn_deadline_seconds
21
41
  end
22
42
 
23
43
  # Broadcast agent status change
@@ -30,18 +50,55 @@ module Collavre
30
50
  agent_id: @agent.id,
31
51
  agent_name: @agent.display_name,
32
52
  task_id: @task.id,
53
+ topic_id: self.class.topic_id_for(task: @task, creative: @creative),
33
54
  content: content,
34
55
  source_creative_id: @creative.id
35
56
  )
36
57
  end
37
58
 
38
- # Check if task was cancelled, raise Collavre::CancelledError if so
39
- def check_cancelled!
59
+ # Check if task reached a terminal status or overran its turn deadline,
60
+ # raise Collavre::CancelledError / Collavre::TurnDeadlineError if so
61
+ def check_cancelled!(force: false)
40
62
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
- return if (now - @last_cancel_check_at) < CANCEL_CHECK_INTERVAL
63
+ return if !force && (now - @last_cancel_check_at) < CANCEL_CHECK_INTERVAL
42
64
 
43
65
  @last_cancel_check_at = now
44
- raise Collavre::CancelledError if @task.reload.status == "cancelled"
66
+ raise Collavre::CancelledError if TERMINAL_STATUSES.include?(@task.reload.status)
67
+
68
+ return if now < @deadline_at
69
+
70
+ # This worker is the only process that knows the turn overran, so it
71
+ # writes the terminal status itself before leaving through the
72
+ # cancellation path (which never overwrites a terminal status).
73
+ #
74
+ # Written the same way StuckDetector fails a row from outside its
75
+ # worker: mark_handed_off! has not run yet (it runs later, in
76
+ # execute_llm_conversation's ensure), so a bare `update!` would let
77
+ # Task's status callback decide off evidence this attempt has not
78
+ # written down yet and restore dispatches this turn actually
79
+ # delivered. fail_while_worker_settles! marks the row so the callback
80
+ # defers, and AiAgentJob's ensure settles it once the real handoff
81
+ # evidence exists.
82
+ deadline_transitioned = Orchestration::DeliveryRecord.fail_while_worker_settles!(@task)
83
+ # Another process may have moved the row to a terminal status after
84
+ # the reload above but before the locked transition. Preserve that
85
+ # winner as an ordinary cancellation; classifying it as our deadline
86
+ # would make AiAgentJob fail a parent workflow that was just cancelled.
87
+ raise Collavre::CancelledError unless deadline_transitioned
88
+
89
+ Rails.logger.warn(
90
+ "[AgentLifecycleManager] Task #{@task.id} exceeded turn deadline " \
91
+ "(#{@turn_deadline_seconds}s); failing"
92
+ )
93
+ raise Collavre::TurnDeadlineError.new(@turn_deadline_seconds)
94
+ end
95
+
96
+ # Bound a single silent provider request by the remaining wall-clock
97
+ # budget. Keep a tiny positive floor for transport APIs that reject zero;
98
+ # check_cancelled! remains the authority once that request returns.
99
+ def remaining_deadline_seconds
100
+ remaining = @deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ [ remaining, 0.001 ].max
45
102
  end
46
103
 
47
104
  # Send heartbeat if interval passed
@@ -54,7 +111,7 @@ module Collavre
54
111
  end
55
112
 
56
113
  # Handle cancellation cleanup
57
- def handle_cancelled(reply_comment:, response_content:)
114
+ def handle_cancelled(reply_comment:, response_content:, action_type: "cancelled", message: "Task cancelled by user")
58
115
  if reply_comment
59
116
  if response_content.present?
60
117
  reply_comment.content_will_change!
@@ -71,7 +128,7 @@ module Collavre
71
128
  end
72
129
 
73
130
  broadcast_status("idle")
74
- log_action("cancelled", { message: "Task cancelled by user" })
131
+ log_action(action_type, { message: message })
75
132
  end
76
133
 
77
134
  private
@@ -16,10 +16,10 @@ module Collavre
16
16
  def handle(error, summary: nil)
17
17
  cleanup_placeholder
18
18
 
19
- broadcast_idle
20
-
21
19
  update_task(error)
22
20
 
21
+ broadcast_pending_approval
22
+
23
23
  log_action(error)
24
24
 
25
25
  create_approval_comment(error, summary: summary)
@@ -31,15 +31,23 @@ module Collavre
31
31
  @reply_comment&.destroy! if @reply_comment&.content == Comment::STREAMING_PLACEHOLDER_CONTENT
32
32
  end
33
33
 
34
- def broadcast_idle
34
+ # The agent has stopped producing output, but the task has not finished:
35
+ # it holds its topic slot until the tool call is approved or rejected, and
36
+ # TasksController#cancel still accepts it. Broadcasting "idle" here made
37
+ # the client drop the task and stop polling, so the pending_approval status
38
+ # this method announces was never observed and Stop disappeared on the one
39
+ # turn that is waiting on the user. Sent after update_task so a client that
40
+ # reacts by polling reads the status this payload claims.
41
+ def broadcast_pending_approval
35
42
  return unless @creative
36
43
 
37
44
  CommentsPresenceChannel.broadcast_agent_status(
38
45
  @creative.effective_origin.id,
39
- status: "idle",
46
+ status: "pending_approval",
40
47
  agent_id: @agent.id,
41
48
  agent_name: @agent.display_name,
42
49
  task_id: @task.id,
50
+ topic_id: @task.topic_id || @task.trigger_event_payload&.dig("topic", "id"),
43
51
  source_creative_id: @creative.id
44
52
  )
45
53
  end
@@ -17,10 +17,9 @@ module Collavre
17
17
 
18
18
  def deliver
19
19
  unless @topic_id
20
- # Workflow subtasks build context without a topic (see
21
- # WorkflowExecutor#build_subtask_context). A Claude Channel agent
22
- # cannot service those — raise so AiAgentJob fails the task and the
23
- # parent workflow advances via fail_subtask! instead of hanging.
20
+ # A Claude Channel agent has no place to deliver a dispatch that
21
+ # carries no topic — raise so AiAgentJob fails the task instead of
22
+ # broadcasting into nowhere.
24
23
  raise UndeliverableError,
25
24
  "Claude Channel delivery requires a topic_id (agent=#{@agent.id})"
26
25
  end
@@ -43,7 +42,13 @@ module Collavre
43
42
  session_topic: session_topic?,
44
43
  comment: {
45
44
  id: @context.dig("comment", "id"),
46
- content: @context.dig("comment", "content"),
45
+ # This payload is the agent's ONLY input — the MCP client gets no
46
+ # chat history — so comments that Orchestration::TaskCoalescer
47
+ # folded into this turn have to be rendered inline here or they
48
+ # never reach the agent at all.
49
+ content: MergedTriggerComments.prepend_to(
50
+ @context.dig("comment", "content"), @context, agent: @agent
51
+ ),
47
52
  author_id: @context.dig("sender", "id") || @context.dig("comment", "user_id"),
48
53
  author_name: @context.dig("sender", "name") || comment&.user&.display_name,
49
54
  topic_id: @topic_id,
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ module AiAgent
5
+ # Renders the comments that Orchestration::TaskCoalescer folded into a task.
6
+ #
7
+ # When a burst of comments collapses into one turn, the superseded comments
8
+ # survive only as ids in the payload's "merged_comment_ids". Every delivery
9
+ # path that carries a trigger has to render them, because none of them can
10
+ # fall back to chat history:
11
+ #
12
+ # - ClaudeChannelAdapter sends *only* comment.content to the MCP client.
13
+ # - SessionContextResolver#incremental_payload sends *only* the :trigger
14
+ # message for session-backed agents.
15
+ #
16
+ # Blocks are chronological and labelled with their speaker, matching how
17
+ # MessageBuilder labels chat history.
18
+ class MergedTriggerComments
19
+ Block = Struct.new(:comment, :text, :images, keyword_init: true)
20
+
21
+ TRUNCATION_SUFFIX = "…[truncated]"
22
+
23
+ def self.for(context, agent:)
24
+ new(context, agent: agent).blocks
25
+ end
26
+
27
+ # The comments a payload's merged ids still refer to *in this turn*.
28
+ #
29
+ # The merged ids were captured when the burst was folded; they are not a
30
+ # standing claim on those rows. CommentMoveService#perform_move reassigns
31
+ # creative_id, so a comment moved away while the task waited is no longer
32
+ # part of the turn — rendering it would hand the agent text and image
33
+ # attachments from a creative it may have no share on.
34
+ #
35
+ # Public because two doors ask the same question and must not diverge:
36
+ # this class decides what is delivered, and Matcher.permits_assignment?
37
+ # decides whether a merged @mention still licenses the turn. A mention
38
+ # excluded from the trigger cannot be the reason the turn is allowed.
39
+ def self.in_turn(ids, context)
40
+ context ||= {}
41
+ relation = Comment.public_only.without_approval_action.where(id: ids)
42
+ creative_id = context.dig("creative", "id")
43
+ relation = relation.where(creative_id: creative_id) if creative_id
44
+ # topic_id nil is a real scope (a creative's Main topic), so key? rather
45
+ # than presence decides whether the turn is topic-scoped at all.
46
+ relation = relation.where(topic_id: context.dig("topic", "id")) if context.key?("topic")
47
+ relation
48
+ end
49
+
50
+ # Prefix `content` with the merged comments, oldest first.
51
+ def self.prepend_to(content, context, agent:)
52
+ blocks = self.for(context, agent: agent)
53
+ return content if blocks.empty?
54
+
55
+ (blocks.map(&:text) + [ content.to_s ]).join("\n\n")
56
+ end
57
+
58
+ def initialize(context, agent:)
59
+ @context = context || {}
60
+ @agent = agent
61
+ end
62
+
63
+ # Ids of the coalesced comments, with the current anchor excluded — the
64
+ # anchor is delivered as the trigger itself and must not be duplicated.
65
+ def comment_ids
66
+ @comment_ids ||= begin
67
+ ids = Array(@context[Orchestration::TaskCoalescer::PAYLOAD_KEY])
68
+ .compact.map(&:to_i).uniq
69
+ anchor_id = @context.dig("comment", "id")
70
+ anchor_id ? ids - [ anchor_id.to_i ] : ids
71
+ end
72
+ end
73
+
74
+ def blocks
75
+ return [] if comment_ids.empty?
76
+
77
+ # Memoized: MessageBuilder renders these into the trigger and also scans
78
+ # them for creative references, and one build should not re-query.
79
+ # Ordered by id, not created_at. A burst is exactly the case where the
80
+ # rows are written by different processes, and created_at is stamped by
81
+ # whichever one wrote it — clock skew or a tie can hand the agent
82
+ # "ignore that" ahead of the instruction it retracts. Ids are the app's
83
+ # single monotonic causal sequence (CommentsController orders by id for
84
+ # the same reason).
85
+ @blocks ||= within_budget(
86
+ self.class.in_turn(comment_ids, @context)
87
+ .includes(:user)
88
+ .order(:id)
89
+ .map { |c| Block.new(comment: c, text: label(c), images: image_blobs(c)) }
90
+ )
91
+ end
92
+
93
+ private
94
+
95
+ # Coalescing turns a burst into one indivisible message, so an oversized
96
+ # burst does not degrade — the whole turn fails. MessageBuilder budgets
97
+ # chat history by the same setting; the trigger these blocks go into had
98
+ # no budget at all, which is the asymmetry a burst is most likely to hit.
99
+ #
100
+ # Shrink, never drop. Dropping the oldest blocks would rest on the history
101
+ # window carrying them instead, and no delivery path guarantees that:
102
+ #
103
+ # - A session-backed agent has no history window at all
104
+ # (SessionContextResolver#incremental_payload keeps only :trigger), and
105
+ # ClaudeChannelAdapter sends only the comment content.
106
+ # - Even with history, MessageBuilder#append_chat_history caps it by
107
+ # chat_history_limit and by *this same* size budget spread across the
108
+ # whole preceding conversation, cutting from its newest end — which is
109
+ # exactly where a just-dropped burst comment sits.
110
+ # - History carries text only. A dropped block's image attachments reach
111
+ # the agent through nothing, whatever the budgets say.
112
+ #
113
+ # So the trigger is the only channel that can promise a merged comment
114
+ # arrives, and "which of the user's comments to delete" is not a choice
115
+ # this class is in a position to make. Every block is kept and truncated to
116
+ # an equal share instead: an oversized burst then degrades in fidelity
117
+ # rather than losing whole messages, and the truncation is visible in the
118
+ # text where a silent drop was not.
119
+ #
120
+ # No floor under the share. A budget too small to say much per comment is
121
+ # still not a reason to delete the other comments outright: keeping them
122
+ # preserves their order, their speakers and — the part the text budget
123
+ # never measures — their image attachments, which are delivered as
124
+ # separate parts and cost nothing here.
125
+ def within_budget(blocks)
126
+ budget = size_limit
127
+ return blocks if budget.nil? || blocks.sum { |b| b.text.length } <= budget
128
+
129
+ blocks.map { |b| truncate_block(b, budget / blocks.size) }
130
+ end
131
+
132
+ def truncate_block(block, limit)
133
+ return block if block.text.length <= limit
134
+
135
+ text = if limit > TRUNCATION_SUFFIX.length
136
+ block.text[0, limit - TRUNCATION_SUFFIX.length] + TRUNCATION_SUFFIX
137
+ else
138
+ block.text[0, limit]
139
+ end
140
+ Block.new(comment: block.comment, text: text, images: block.images)
141
+ end
142
+
143
+ def size_limit
144
+ @agent.respond_to?(:chat_history_size_limit) ? @agent.chat_history_size_limit : nil
145
+ end
146
+
147
+ def label(comment)
148
+ text = comment.content.to_s
149
+ if @agent && comment.user_id == @agent.id
150
+ # Defensive: an agent's own message should never be coalesced into its
151
+ # own trigger, but if one is, do not relabel it as a human speaker.
152
+ "[#{@agent.name}]: #{text}"
153
+ else
154
+ text = MentionParser.strip_self_mention(text, @agent.name) if @agent
155
+ "[#{comment.user&.name || 'unknown'}]: #{text}"
156
+ end
157
+ end
158
+
159
+ def image_blobs(comment)
160
+ comment.images.attached? ? comment.images.map(&:blob) : []
161
+ end
162
+ end
163
+ end
164
+ end
@@ -106,14 +106,18 @@ module Collavre
106
106
  end
107
107
 
108
108
  def append_referenced_creative_contexts(messages)
109
- content = @context.dig("comment", "content")
110
- return unless content
109
+ # Comments coalesced into this turn are delivered with the trigger, so
110
+ # their links point at creatives the agent is about to be asked about.
111
+ # Scanning only the surviving anchor would drop the subtree that the
112
+ # absorbed comment's own dispatch would have supplied.
113
+ contents = [ @context.dig("comment", "content") ] + merged_trigger.blocks.map(&:text)
114
+ contents = contents.compact
115
+ return if contents.empty?
111
116
 
112
117
  children_level = @agent.creative_children_level
113
118
  max_depth = 1 + children_level
114
119
 
115
- # Extract creative IDs from markdown links like [title](/creatives/123)
116
- referenced_ids = content.scan(%r{\[[^\]]*\]\(/creatives/(\d+)\)}).flatten.map(&:to_i).uniq
120
+ referenced_ids = contents.flat_map { |c| referenced_creative_ids(c) }.uniq
117
121
  referenced_ids.reject! { |cid| @injected_creative_ids.include?(cid) }
118
122
  creatives_by_id = Creative.where(id: referenced_ids).index_by(&:id)
119
123
 
@@ -134,6 +138,44 @@ module Collavre
134
138
  end
135
139
  end
136
140
 
141
+ # Creative ids linked from markdown like [title](/creatives/123).
142
+ def referenced_creative_ids(text)
143
+ text.to_s.scan(%r{\[[^\]]*\]\(/creatives/(\d+)\)}).flatten.map(&:to_i).uniq
144
+ end
145
+
146
+ # Whether a history entry carries its comment whole.
147
+ #
148
+ # A history entry is text: this window renders `content` and nothing
149
+ # else. Two things ride only on the trigger path — the blobs
150
+ # append_trigger_message attaches, and the subtree
151
+ # append_referenced_creative_contexts renders for a linked creative,
152
+ # which scans the anchor and the merged blocks and never this window. A
153
+ # comment carrying either is *partly* delivered here, and the turn that
154
+ # swept it up is not a substitute for its own dispatch.
155
+ #
156
+ # Decided here rather than at Orchestration::DeliveryRecord, which reads
157
+ # the :comment_id tag as a delivery claim: this is the only place that
158
+ # knows what was rendered, and in particular which creatives were
159
+ # already injected. A link to the topic's own creative — the common case
160
+ # — withholds nothing, and a rule written anywhere else could not tell
161
+ # that apart from a link to a subtree the agent never saw.
162
+ #
163
+ # Deliberately the same test append_referenced_creative_contexts applies,
164
+ # @injected_creative_ids and all, so the two answer alike: whatever that
165
+ # path would have injected for this comment is what this window failed to
166
+ # supply. It holds only roots, not the descendants a rendered subtree
167
+ # also covers, so a link to a descendant reads as withheld when its text
168
+ # was in fact rendered. That asymmetry costs a redundant turn; the
169
+ # opposite one costs the message.
170
+ def delivered_whole?(comment)
171
+ return false if comment.images.attached?
172
+
173
+ missing = referenced_creative_ids(comment.content) - @injected_creative_ids.to_a
174
+ return true if missing.empty?
175
+
176
+ !Creative.where(id: missing).exists?
177
+ end
178
+
137
179
  # Appends chat history messages and returns the count of messages added.
138
180
  def append_chat_history(messages)
139
181
  creative_id = @context.dig("creative", "id")
@@ -146,16 +188,23 @@ module Collavre
146
188
  history_chars = 0
147
189
  count = 0
148
190
 
149
- Comment.public_only.without_approval_action.where(creative_id: creative_id)
150
- .where(topic_id: topic_id)
151
- .where.not(user_id: nil)
152
- .includes(:user)
153
- .order(created_at: :desc)
154
- .limit(history_limit)
155
- .reverse
156
- .each do |c|
157
- next if c.id == trigger_comment&.id
158
-
191
+ scope = Comment.public_only.without_approval_action.where(creative_id: creative_id)
192
+ .where(topic_id: topic_id)
193
+ .where.not(user_id: nil)
194
+ .includes(:user, :images_attachments)
195
+
196
+ # Exclude before limiting, not after. The trigger and the comments folded
197
+ # into it are delivered by append_trigger_message, so leaving them in the
198
+ # limited window lets a burst eat every history slot — a burst as large as
199
+ # the limit would leave no conversation at all and mark the turn
200
+ # first_message. Dropping them first lets older messages backfill.
201
+ excluded_ids = (merged_comment_ids + [ @context.dig("comment", "id") ]).compact.map(&:to_i)
202
+ scope = scope.where.not(id: excluded_ids) if excluded_ids.any?
203
+
204
+ scope.order(created_at: :desc)
205
+ .limit(history_limit)
206
+ .reverse
207
+ .each do |c|
159
208
  role = (c.user_id == @agent.id) ? "model" : "user"
160
209
  content = c.content.to_s
161
210
 
@@ -168,7 +217,18 @@ module Collavre
168
217
  history_chars += content.length
169
218
  break if history_chars > history_size_limit
170
219
 
171
- messages << { role: role, kind: :chat_history, parts: [ { text: content } ] }
220
+ # Tagged with the comment it came from so Orchestration::DeliveryRecord
221
+ # can read, off the payload the adapter is actually handed, which
222
+ # comments this turn delivered. Consumers key into :role and :parts;
223
+ # the extra key is inert to all of them.
224
+ #
225
+ # Only an entry that carries its comment whole wears the tag — the
226
+ # tag is the delivery claim, and delivered_whole? is what makes it
227
+ # true. An untagged entry still goes to the agent as context; it just
228
+ # does not stand in for the comment's own dispatch.
229
+ entry = { role: role, kind: :chat_history, parts: [ { text: content } ] }
230
+ entry[:comment_id] = c.id if delivered_whole?(c)
231
+ messages << entry
172
232
  count += 1
173
233
  end
174
234
 
@@ -176,7 +236,7 @@ module Collavre
176
236
  end
177
237
 
178
238
  def append_trigger_message(messages)
179
- payload_text = @context.dig("comment", "content") || @context.to_json
239
+ payload_text = trigger_payload_text
180
240
 
181
241
  if review_eligible?
182
242
  quoted_body = @original_comment.quoted_comment&.content
@@ -192,8 +252,17 @@ module Collavre
192
252
  payload_text = "[#{sender_name}]: #{payload_text}"
193
253
  end
194
254
 
255
+ # Comments that were folded into this task by Orchestration::TaskCoalescer
256
+ # (a burst of messages answered as one turn). They belong *in* the
257
+ # trigger, not in chat history: a session-backed agent receives only the
258
+ # :trigger message, so history-only placement would lose them entirely.
259
+ merged_blocks = merged_trigger.blocks
260
+ payload_text = (merged_blocks.map(&:text) + [ payload_text ]).join("\n\n") if merged_blocks.any?
261
+
195
262
  trigger_parts = [ { text: payload_text } ]
196
263
 
264
+ merged_blocks.each { |b| b.images.each { |blob| trigger_parts << { image: blob } } }
265
+
197
266
  if @original_comment&.images&.attached?
198
267
  @original_comment.images.each do |image|
199
268
  trigger_parts << { image: image.blob }
@@ -203,6 +272,24 @@ module Collavre
203
272
  messages << { role: "user", kind: :trigger, parts: trigger_parts }
204
273
  end
205
274
 
275
+ def trigger_payload_text
276
+ @context.dig("comment", "content") || @context.except(SystemEvents::Envelope::KEY).to_json
277
+ end
278
+
279
+ def merged_trigger
280
+ @merged_trigger ||= MergedTriggerComments.new(@context, agent: @agent)
281
+ end
282
+
283
+ # Ids of the coalesced comments this turn actually renders into the
284
+ # trigger (anchor excluded). Deliberately the rendered blocks rather than
285
+ # every merged id: MergedTriggerComments drops the oldest of an oversized
286
+ # burst, and excluding a comment that no longer appears in the trigger
287
+ # would delete it from the turn outright. Leaving it in the history scope
288
+ # lets that separately budgeted window carry it instead.
289
+ def merged_comment_ids
290
+ merged_trigger.blocks.filter_map { |b| b.comment&.id }
291
+ end
292
+
206
293
  def trigger_comment
207
294
  @trigger_comment ||= begin
208
295
  id = @context.dig("comment", "id")
@@ -32,9 +32,6 @@ module Collavre
32
32
  existing = User.find_by(email: email)
33
33
  return verified_agent(existing) if existing
34
34
 
35
- # routing_expression: nil so the new ai_user is not matchable until the
36
- # client claims the per-agent stream via AgentChannel. See the comment on
37
- # verified_agent.
38
35
  User.create!(
39
36
  email: email,
40
37
  name: "Claude Channel (#{agent_name})",
@@ -42,8 +39,7 @@ module Collavre
42
39
  llm_vendor: "anthropic",
43
40
  llm_model: "claude-code",
44
41
  created_by_id: @user.id,
45
- searchable: false,
46
- routing_expression: nil
42
+ searchable: false
47
43
  )
48
44
  rescue ActiveRecord::RecordNotUnique
49
45
  # A concurrent registration for the same (user, agent_name) won the
@@ -92,9 +88,9 @@ module Collavre
92
88
  end
93
89
 
94
90
  # Returns the agent only when it is the current user's own Claude Channel
95
- # agent; nil otherwise (the caller renders :conflict). Routing activation
91
+ # agent; nil otherwise (the caller renders :conflict). Presence creation
96
92
  # stays deferred to AgentChannel#subscribe_to_agent_stream so the agent
97
- # becomes matchable only once a WebSocket subscriber exists for
93
+ # becomes ambiently routable only once a WebSocket subscriber exists for
98
94
  # agent:user:<id> — otherwise comments matched between register returning
99
95
  # and the client's subsequent cable subscribe would broadcast into an empty
100
96
  # stream, stranding delegated tasks until stuck recovery.
@@ -14,12 +14,15 @@ module Collavre
14
14
  # echoes the dispatch's task_id). Without task_id (legacy clients),
15
15
  # oldest-first.
16
16
  # 2. Inside a transaction: SELECT FOR UPDATE the row, re-check
17
- # status == 'delegated' under the lock, then update! to 'done'.
17
+ # status == 'delegated' under the lock, then update it to 'running'.
18
18
  # Concurrent claimers block on the lock; the loser sees the
19
19
  # already-flipped status post-lock and returns nil so the caller can
20
20
  # refuse the duplicate.
21
- # update_all (NOT update!) is required to skip Task's after_update_commit
22
- # callbacks at claim time. The callbacks fire check_trigger_loop_completion
21
+ # running is a transient claimed-reply state. It remains active while the
22
+ # comment transaction commits, so TopicMove cannot slip between claim and
23
+ # finalize; another /reply cannot claim it because only delegated rows are
24
+ # eligible. update_all (NOT update!) is required to skip callbacks at
25
+ # claim time. The callbacks fire check_trigger_loop_completion
23
26
  # (which enqueues TriggerLoopCheckJob) and broadcast_stop_button_removal
24
27
  # (which reads reply_comment). Both depend on the reply comment already
25
28
  # existing — but reply() claims BEFORE comment.save to win the race against
@@ -43,25 +46,38 @@ module Collavre
43
46
  locked = Task.lock.find_by(id: candidate.id)
44
47
  next unless locked && locked.status == "delegated"
45
48
 
46
- Task.where(id: locked.id).update_all(status: "done", pending_tool_call: nil, updated_at: Time.current)
49
+ Task.where(id: locked.id).update_all(status: "running", pending_tool_call: nil, updated_at: Time.current)
47
50
  claimed = locked.reload
48
51
  end
49
52
  claimed
50
53
  end
51
54
 
52
- # Post-claim side effects, run only after the reply comment is saved. Links
53
- # the comment to the claimed task, releases the ResourceTracker slot the
54
- # AiAgentJob held under task.id, advances the parent workflow (if any), and
55
- # drains the topic queue — mirroring AiAgentJob#perform's success path for
56
- # non-delegated runs.
57
- def finalize(agent:, task:, comment:)
55
+ def link_reply(task:, comment:)
58
56
  comment.update_column(:task_id, task.id)
57
+ end
59
58
 
60
- Orchestration::ResourceTracker.for(agent).release!(task.id)
59
+ # Finalize the claimed row inside TaskReplyService's topic-lock
60
+ # transaction. Completion effects are registered after commit, when the
61
+ # linked comment and done status are both visible. This leaves no gap for
62
+ # cancellation or TopicMove between reply persistence and completion.
63
+ # The effects release the ResourceTracker slot the
64
+ # AiAgentJob held under task.id, and drains the topic queue — mirroring AiAgentJob#perform's success path for
65
+ # non-delegated runs.
66
+ def finalize(agent:, task:, comment:)
67
+ completed = Task.where(id: task.id, status: "running")
68
+ .update_all(status: "done", updated_at: Time.current)
69
+ raise ActiveRecord::RecordNotSaved, "claimed reply task was not running" unless completed == 1
61
70
 
62
- if task.parent_task_id.present?
63
- Collavre::Comments::WorkflowExecutor.new(task.parent_task).complete_subtask!(task)
71
+ task.reload
72
+ ActiveRecord.after_all_transactions_commit do
73
+ run_completion_effects(agent, task, comment)
64
74
  end
75
+ end
76
+
77
+ private
78
+
79
+ def run_completion_effects(agent, task, comment)
80
+ Orchestration::ResourceTracker.for(agent).release!(task.id)
65
81
 
66
82
  Orchestration::AgentOrchestrator.dequeue_next_for_topic(task.topic_id, task.creative_id)
67
83
 
@@ -79,8 +95,6 @@ module Collavre
79
95
  broadcast_claude_idle(agent, task, comment)
80
96
  end
81
97
 
82
- private
83
-
84
98
  # Clear the chat typing indicator via the canonical status broadcaster (the
85
99
  # same one AiAgentService uses for every other agent), so the Claude path
86
100
  # emits an identical "idle" agent_status payload.