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
@@ -24,19 +24,54 @@ class CommentsPresenceChannel < ApplicationCable::Channel
24
24
  )
25
25
  end
26
26
 
27
- # Broadcast status for any currently running AI agent tasks for a creative.
28
- # Called when a user subscribes to ensure they see ongoing agent activity.
29
- def self.broadcast_running_agents(creative_id)
30
- Task.where(status: %w[running pending]).find_each do |task|
27
+ # Replayed to late subscribers. "pending_approval" must be here: its job already
28
+ # returned, so no heartbeat or later broadcast will ever re-announce it, and a
29
+ # reloading viewer would lose the Stop button on the turn that is waiting on them.
30
+ AGENT_REPLAY_STATUSES = %w[running pending pending_approval].freeze
31
+
32
+ # Narrowed by creative in SQL, not in Ruby: a pending_approval turn sits until
33
+ # somebody answers it, so the replayable set is unbounded and deployment-wide,
34
+ # and every subscribe would otherwise load all of it just to keep one creative's
35
+ # rows. tasks.creative_id is written from the same context["creative"]["id"] the
36
+ # payload carries (AiAgentJob#admit_or_defer!, park_waiter), and is indexed.
37
+ # A row can only lose it to the FK's on_delete:
38
+ # :nullify, and a deleted creative already returns [] above.
39
+ #
40
+ # Ordered by id because the client appends these into a per-agent array and stops
41
+ # the last one. Not created_at: turns are stamped by whichever process dispatched
42
+ # them, so a web/worker clock skew can invert two of them (see PR #1431).
43
+ def self.replayable_tasks(creative_id)
44
+ Task.where(status: AGENT_REPLAY_STATUSES, creative_id: creative_id).order(:id)
45
+ end
46
+
47
+ # Scoped to the creative the popup is open on, not to its origin: the client keeps
48
+ # that id as this.creativeId and drops any payload naming a different one, and a
49
+ # link is a placement — replaying every task under the origin would put another
50
+ # user's shell id on the wire, which PermissionFilter#readable_ids exists to hide.
51
+ # Topics hang off the origin, so that is what resolves a task's topic_id.
52
+ def self.running_agent_payloads(creative_id, topic_id: nil)
53
+ creative = Creative.find_by(id: creative_id)&.effective_origin
54
+ return [] unless creative
55
+
56
+ replayable_tasks(creative_id).filter_map do |task|
57
+ # Still checked: the column is what the query trusts, the payload is what the
58
+ # client is handed back as source_creative_id, and a row where the two
59
+ # disagree must not name a creative it was not dispatched on.
31
60
  task_creative_id = task.trigger_event_payload&.dig("creative", "id")
32
61
  next unless task_creative_id == creative_id
33
62
 
34
- broadcast_agent_status(
63
+ task_topic_id = AiAgent::AgentLifecycleManager.topic_id_for(task: task, creative: creative)
64
+ next if topic_id && task_topic_id.to_s != topic_id.to_s
65
+
66
+ agent_status_payload(
35
67
  creative_id,
36
- status: "thinking",
68
+ # Replayed with its real status, not a blanket "thinking": the client keys
69
+ # its own handling off this string, and a paused turn is not a working one.
70
+ status: task.status == "pending_approval" ? "pending_approval" : "thinking",
37
71
  agent_id: task.agent_id,
38
72
  agent_name: task.agent.display_name,
39
73
  task_id: task.id,
74
+ topic_id: task_topic_id,
40
75
  source_creative_id: task_creative_id
41
76
  )
42
77
  end
@@ -45,62 +80,187 @@ class CommentsPresenceChannel < ApplicationCable::Channel
45
80
  # Broadcast agent status (thinking/streaming/idle) to presence channel.
46
81
  # This allows the frontend typing indicator to show AI agent activity.
47
82
  # source_creative_id: the actual creative where agent is working (for filtering on frontend)
48
- def self.broadcast_agent_status(creative_id, status:, agent_id:, agent_name:, task_id: nil, content: nil, source_creative_id: nil)
83
+ def self.broadcast_agent_status(creative_id, status:, agent_id:, agent_name:, topic_id:, task_id: nil, content: nil, source_creative_id: nil)
84
+ ActionCable.server.broadcast(
85
+ "comments_presence:#{creative_id}",
86
+ agent_status_payload(
87
+ creative_id,
88
+ status: status, agent_id: agent_id, agent_name: agent_name, topic_id: topic_id,
89
+ task_id: task_id, content: content, source_creative_id: source_creative_id
90
+ )
91
+ )
92
+ end
93
+
94
+ def self.agent_status_payload(creative_id, status:, agent_id:, agent_name:, topic_id:, task_id: nil, content: nil, source_creative_id: nil)
49
95
  payload = {
50
96
  agent_status: {
51
97
  id: agent_id,
52
98
  name: agent_name,
53
99
  status: status,
54
100
  task_id: task_id,
101
+ topic_id: topic_id,
55
102
  creative_id: source_creative_id || creative_id
56
103
  }
57
104
  }
58
105
  payload[:agent_status][:content] = content if content.present?
59
- ActionCable.server.broadcast("comments_presence:#{creative_id}", payload)
106
+ payload
60
107
  end
61
108
 
62
109
  def subscribed
63
110
  Rails.logger.info "User #{current_user&.email} subscribed to comments presence for creative #{params[:creative_id]}"
64
- return unless params[:creative_id].present? && current_user
111
+ return reject unless params[:creative_id].present? && current_user
112
+
113
+ requested = Creative.find_by(id: params[:creative_id].to_i)
114
+ creative = requested&.effective_origin
115
+ # The only gate on this channel — nothing below is re-checked per message, and
116
+ # active_statuses already filters the same task data by :read.
117
+ return reject unless creative&.has_permission?(current_user, :read)
65
118
 
66
- @creative_id = Creative.find(params[:creative_id].to_i).effective_origin.id
67
- creative = Creative.find(@creative_id)
119
+ @creative_id = creative.id
120
+ # Kept for the whole subscription, not just for the replay below: comments live on
121
+ # the origin, so everything else on this channel is keyed by @creative_id, while
122
+ # a task is dispatched on — and replayed for — the creative the popup is open on.
123
+ # #running_agents replays too, and reading @creative_id there would search the
124
+ # origin for rows that only ever carried the shell's id.
125
+ @requested_creative_id = requested.id
126
+ @presence_subscription_id = SecureRandom.uuid
68
127
  stream_from stream_name
69
- CommentPresenceStore.add(@creative_id, current_user.id)
128
+ CommentPresenceStore.add(@creative_id, current_user.id, subscription_id: @presence_subscription_id)
70
129
  Comment.broadcast_badge(creative, current_user)
71
130
  broadcast_presence
72
- CommentsPresenceChannel.broadcast_running_agents(@creative_id)
131
+ # Transmitted, not broadcast: stream_from attaches asynchronously, so a broadcast
132
+ # published in the same breath can be dropped. Presence and badges survive that
133
+ # (later events re-send them); this replay is one-shot.
134
+ replay_running_agents
73
135
  end
74
136
 
75
137
  def unsubscribed
76
138
  if @creative_id && current_user
77
- CommentPresenceStore.remove(@creative_id, current_user.id)
139
+ CommentPresenceStore.remove(@creative_id, current_user.id, subscription_id: @presence_subscription_id)
78
140
  creative = Creative.find(@creative_id)
79
- pointer = CommentReadPointer.find_or_initialize_by(user: current_user, creative: creative)
80
- pointer.last_read_comment_id = creative.comments.maximum(:id)
81
- pointer.save!
82
141
  Comment.broadcast_badge(creative, current_user)
83
142
  broadcast_presence
84
143
  end
85
144
  end
86
145
 
87
- def typing
146
+ # Presence is creative-wide for participant avatars, but unread suppression is
147
+ # topic-specific. A client reports the topic it is actively rendering so a
148
+ # message in another topic keeps its badge while the chat popup is open. All
149
+ # Messages also reports the topic snapshot rendered in its list: an active
150
+ # topic restored later has not yet been viewed.
151
+ def viewing_topic(data)
152
+ return unless @creative_id && current_user
153
+
154
+ topic_id = data["topic_id"] || data[:topic_id]
155
+ @viewing_topic_id = topic_id.present? && Topic.find_by(id: topic_id, creative_id: @creative_id)&.id
156
+ return if topic_id.present? && !@viewing_topic_id
157
+ @rendered_topic_ids = rendered_topic_ids_for(data) unless @viewing_topic_id
158
+ @rendered_legacy_topic = rendered_legacy_topic_for(data) unless @viewing_topic_id
159
+ @viewing_topic_reported = true
160
+
161
+ CommentPresenceStore.set_topic(
162
+ @creative_id,
163
+ current_user.id,
164
+ @viewing_topic_id,
165
+ subscription_id: @presence_subscription_id,
166
+ rendered_topic_ids: @rendered_topic_ids,
167
+ rendered_legacy_topic: @rendered_legacy_topic
168
+ )
169
+ Comment.broadcast_badge(Creative.find(@creative_id), current_user)
170
+ end
171
+
172
+ # The browser keeps this lease alive while its Action Cable subscription is
173
+ # healthy. If the process or connection disappears without #unsubscribed,
174
+ # the lease simply expires and no longer suppresses unread badges.
175
+ def heartbeat
176
+ return unless @creative_id && current_user
177
+
178
+ return if CommentPresenceStore.renew(
179
+ @creative_id,
180
+ current_user.id,
181
+ subscription_id: @presence_subscription_id
182
+ )
183
+
184
+ # A frozen browser can resume on an intact WebSocket after its cache lease
185
+ # expires. Recreate this live channel's membership from its last reported
186
+ # rendering context instead of waiting for a topic switch or resubscribe.
187
+ if @viewing_topic_reported
188
+ CommentPresenceStore.set_topic(
189
+ @creative_id,
190
+ current_user.id,
191
+ @viewing_topic_id,
192
+ subscription_id: @presence_subscription_id,
193
+ rendered_topic_ids: @rendered_topic_ids,
194
+ rendered_legacy_topic: @rendered_legacy_topic
195
+ )
196
+ else
197
+ CommentPresenceStore.add(@creative_id, current_user.id, subscription_id: @presence_subscription_id)
198
+ end
199
+ Comment.broadcast_badge(Creative.find(@creative_id), current_user)
200
+ broadcast_presence
201
+ end
202
+
203
+ def typing(data)
88
204
  return unless @creative_id && current_user
89
205
 
206
+ topic_id = topic_id_for(data)
207
+ return unless topic_id
208
+
90
209
  ActionCable.server.broadcast(
91
210
  stream_name,
92
- { typing: { id: current_user.id, name: current_user.display_name } }
211
+ { typing: { id: current_user.id, name: current_user.display_name, topic_id: topic_id } }
93
212
  )
94
213
  end
95
214
 
96
- def stopped_typing
215
+ def stopped_typing(data)
216
+ return unless @creative_id && current_user
217
+
218
+ topic_id = topic_id_for(data)
219
+ return unless topic_id
220
+
221
+ ActionCable.server.broadcast(stream_name, { stop_typing: { id: current_user.id, topic_id: topic_id } })
222
+ end
223
+
224
+ # The client clears its agent state on every topic switch and asks for the new
225
+ # topic's snapshot. A pending_approval turn has no heartbeat behind it, so this
226
+ # replay is the only thing that can put its indicator and Stop button back.
227
+ def running_agents(data)
97
228
  return unless @creative_id && current_user
98
229
 
99
- ActionCable.server.broadcast(stream_name, { stop_typing: { id: current_user.id } })
230
+ topic_id = topic_id_for(data)
231
+ return unless topic_id
232
+
233
+ replay_running_agents(topic_id: topic_id)
100
234
  end
101
235
 
102
236
  private
103
237
 
238
+ def rendered_topic_ids_for(data)
239
+ ids = data["rendered_topic_ids"] || data[:rendered_topic_ids]
240
+ return [] unless ids.is_a?(Array)
241
+
242
+ Topic.where(creative_id: @creative_id, id: ids).pluck(:id)
243
+ end
244
+
245
+ def rendered_legacy_topic_for(data)
246
+ ActiveModel::Type::Boolean.new.cast(data["rendered_legacy_topic"] || data[:rendered_legacy_topic])
247
+ end
248
+
249
+ # Transmitted to the connection that asked, never broadcast: the stream is per
250
+ # origin and shared by every viewer of it, so publishing one client's snapshot
251
+ # there would re-push agent state to everyone — including viewers sitting on a
252
+ # different topic, whose own state the client would then have to reconcile.
253
+ def replay_running_agents(topic_id: nil)
254
+ self.class
255
+ .running_agent_payloads(@requested_creative_id, topic_id: topic_id)
256
+ .each { |payload| transmit(payload) }
257
+ end
258
+
259
+ def topic_id_for(data)
260
+ topic_id = data["topic_id"] || data[:topic_id]
261
+ Topic.find_by(id: topic_id, creative_id: @creative_id)&.id
262
+ end
263
+
104
264
  def stream_name
105
265
  "comments_presence:#{@creative_id}"
106
266
  end
@@ -23,7 +23,13 @@ module Collavre
23
23
  # badge stale. transmit only reaches this just-confirmed subscriber, so the
24
24
  # snapshot can never be sent while no client is listening.
25
25
  inbox = Creative.inbox_for(current_user)
26
- snapshot = Comment.inbox_badge_turbo_stream(inbox, current_user)
26
+ badge_index = Creatives::CommentBadgeIndex.new(user: current_user)
27
+ badge_index.index([ inbox ])
28
+ snapshot = Comment.inbox_badge_turbo_stream(
29
+ inbox,
30
+ current_user,
31
+ count: badge_index.unread_count_for(inbox)
32
+ )
27
33
  transmit(snapshot) if snapshot
28
34
  end
29
35
  end
@@ -14,24 +14,17 @@ module Collavre
14
14
  def count
15
15
  return @count if @count
16
16
 
17
- if @creative && @user
18
- # Use CommentReadPointer-based unread count for inbox creative
19
- unread_count_for_creative
20
- else
21
- # Legacy fallback: InboxItem count
22
- InboxItem.where(owner: @user, state: "new").count
23
- end
17
+ return 0 unless @creative && @user
18
+
19
+ unread_count_for_creative
24
20
  end
25
21
 
26
22
  private
27
23
 
28
24
  def unread_count_for_creative
29
- visible_comments = @creative.comments.visible_to(@user)
30
- pointer = CommentReadPointer.find_by(user: @user, creative: @creative)
31
- last_read_id = pointer&.last_read_comment_id
32
-
33
- scope = last_read_id ? visible_comments.where("comments.id > ?", last_read_id) : visible_comments
34
- scope.count
25
+ badge_index = Creatives::CommentBadgeIndex.new(user: @user)
26
+ badge_index.index([ @creative.effective_origin ])
27
+ badge_index.unread_count_for(@creative.effective_origin)
35
28
  end
36
29
  end
37
30
  end
@@ -1,5 +1,5 @@
1
- <div class="progress-filter-group" data-controller="progress-filter">
1
+ <div class="progress-filter-group" data-controller="progress-filter" data-progress-filter-index-path-value="<%= index_path %>" data-progress-filter-on-index-value="<%= on_index? %>">
2
2
  <% states.each do |state| %>
3
- <button class="progress-filter-btn<%= ' active' if current_state.to_s == state[:value].to_s %>" data-action="click->progress-filter#apply" data-progress-filter-target="button" data-progress-filter-filter-param="<%= state[:value] %>"><%= state[:name] %></button>
3
+ <button class="progress-filter-btn<%= ' active' if current_state.to_s == state[:value].to_s %>" data-action="click->progress-filter#apply" data-progress-filter-target="button" data-filter-state="<%= filter_state_key(state[:value]) %>" data-progress-filter-filter-param="<%= state[:value] %>"><%= state[:name] %></button>
4
4
  <% end %>
5
5
  </div>
@@ -1,10 +1,30 @@
1
1
  module Collavre
2
2
  class ProgressFilterComponent < ViewComponent::Base
3
+ # Progress/comment params are only consumed by Creatives::IndexQuery, so the
4
+ # buttons carry the creative index path and whether we are already on it.
5
+ # See app/javascript/lib/utils/filter_navigation.js.
6
+ PROGRESS_VALUES = %w[all complete incomplete].freeze
7
+
3
8
  def initialize(current_state:, states: [])
4
9
  @current_state = current_state&.to_sym
5
10
  @states = states
6
11
  end
7
12
 
8
13
  attr_reader :current_state, :states
14
+
15
+ def index_path
16
+ helpers.collavre.creatives_path
17
+ end
18
+
19
+ def on_index?
20
+ helpers.creative_index_page?
21
+ end
22
+
23
+ # Mirrors the keys syncFilterButtons() derives from the URL, so a frame-only
24
+ # navigation can re-apply the `active` class without a server round trip.
25
+ def filter_state_key(value)
26
+ value = value.to_s
27
+ PROGRESS_VALUES.include?(value) ? "progress:#{value}" : value
28
+ end
9
29
  end
10
30
  end
@@ -24,6 +24,7 @@ module Collavre
24
24
 
25
25
  # LLM settings
26
26
  @llm_request_timeout_seconds = SystemSetting.llm_request_timeout_seconds
27
+ @ai_agent_turn_deadline_seconds = SystemSetting.ai_agent_turn_deadline_seconds
27
28
 
28
29
  # Rate limiting settings
29
30
  @password_reset_rate_limit = SystemSetting.password_reset_rate_limit
@@ -108,6 +109,7 @@ module Collavre
108
109
  @api_rate_limit = params[:api_rate_limit].to_i.positive? ? params[:api_rate_limit].to_i : SystemSetting::DEFAULT_API_RATE_LIMIT
109
110
  @api_rate_period_minutes = params[:api_rate_period_minutes].to_i.positive? ? params[:api_rate_period_minutes].to_i : SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES
110
111
  @llm_request_timeout_seconds = params[:llm_request_timeout_seconds].to_i.positive? ? params[:llm_request_timeout_seconds].to_i : SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS
112
+ @ai_agent_turn_deadline_seconds = params[:ai_agent_turn_deadline_seconds].to_i.positive? ? params[:ai_agent_turn_deadline_seconds].to_i : SystemSetting::DEFAULT_AI_AGENT_TURN_DEADLINE_SECONDS
111
113
  @enabled_auth_providers = params[:auth_providers] || []
112
114
  render :index, status: :unprocessable_entity
113
115
  end
@@ -130,7 +132,8 @@ module Collavre
130
132
  "password_reset_rate_period_minutes" => int_setting(:password_reset_rate_period_minutes, SystemSetting::DEFAULT_PASSWORD_RESET_RATE_PERIOD_MINUTES),
131
133
  "api_rate_limit" => int_setting(:api_rate_limit, SystemSetting::DEFAULT_API_RATE_LIMIT),
132
134
  "api_rate_period_minutes" => int_setting(:api_rate_period_minutes, SystemSetting::DEFAULT_API_RATE_PERIOD_MINUTES),
133
- "llm_request_timeout_seconds" => int_setting(:llm_request_timeout_seconds, SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, min: 30)
135
+ "llm_request_timeout_seconds" => int_setting(:llm_request_timeout_seconds, SystemSetting::DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, min: 30),
136
+ "ai_agent_turn_deadline_seconds" => int_setting(:ai_agent_turn_deadline_seconds, SystemSetting::DEFAULT_AI_AGENT_TURN_DEADLINE_SECONDS, min: 60)
134
137
  }
135
138
  end
136
139
 
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class AgentConnectionsController < ApplicationController
5
+ before_action :set_agent_and_workspace
6
+
7
+ def show; end
8
+
9
+ def status
10
+ client = proxy_client
11
+ engines = Array(client.engines["data"])
12
+ engine_rows = engines.map do |engine|
13
+ engine.merge("status" => client.engine_status(engine.fetch("engine")))
14
+ rescue Collavre::CliProxy::Client::Error => e
15
+ engine.merge("status" => { "state" => "unknown", "error" => e.message, "code" => e.code })
16
+ end
17
+
18
+ render json: {
19
+ engines: engine_rows,
20
+ provision: client.provision_status,
21
+ workspace: {
22
+ credential_id: @workspace.proxy_credential_id,
23
+ workspace_id: @workspace.proxy_workspace_id,
24
+ mode: @agent.agent_gateway.workspace_mode
25
+ }
26
+ }
27
+ rescue Collavre::CliProxy::Client::Error => e
28
+ render_proxy_error(e)
29
+ end
30
+
31
+ def create_auth_session
32
+ response = proxy_client.create_auth_session(
33
+ params[:engine],
34
+ flow: params[:flow].presence,
35
+ provisioning_url: manifest_url
36
+ )
37
+ render json: response, status: :created
38
+ rescue Collavre::CliProxy::Client::Error => e
39
+ render_proxy_error(e)
40
+ end
41
+
42
+ def auth_session
43
+ render json: proxy_client.auth_session(params[:engine], params[:session_id])
44
+ rescue Collavre::CliProxy::Client::Error => e
45
+ render_proxy_error(e)
46
+ end
47
+
48
+ def submit_auth_session
49
+ base_url = params[:base_url].presence
50
+ validate_custom_gateway_base_url!(base_url) if base_url
51
+ render json: proxy_client.submit_auth_session(
52
+ params[:engine],
53
+ params[:session_id],
54
+ params[:auth_secret],
55
+ base_url: base_url
56
+ )
57
+ rescue Collavre::CliProxy::Client::Error => e
58
+ render_proxy_error(e)
59
+ end
60
+
61
+ def cancel_auth_session
62
+ render json: proxy_client.cancel_auth_session(params[:engine], params[:session_id])
63
+ rescue Collavre::CliProxy::Client::Error => e
64
+ render_proxy_error(e)
65
+ end
66
+
67
+ def provision_sync
68
+ render json: sync_provisioning
69
+ rescue Collavre::CliProxy::Client::Error => e
70
+ render_proxy_error(e)
71
+ end
72
+
73
+ def provision_approve
74
+ render json: proxy_client.provision_approve(params[:type], params[:name])
75
+ rescue Collavre::CliProxy::Client::Error => e
76
+ render_proxy_error(e)
77
+ end
78
+
79
+ def provision_delete
80
+ render json: proxy_client.provision_delete(params[:type], params[:name])
81
+ rescue Collavre::CliProxy::Client::Error => e
82
+ render_proxy_error(e)
83
+ end
84
+
85
+ def rotate_tokens
86
+ @workspace.rotate_tokens!
87
+ redirect_to agent_connection_user_path(@agent), notice: t("collavre.agent_connections.tokens_rotated")
88
+ end
89
+
90
+ private
91
+
92
+ def set_agent_and_workspace
93
+ @agent = Collavre::User.find(params[:user_id] || params[:id])
94
+ return head :not_found unless @agent.cli_proxy_agent?
95
+ return head :forbidden unless @agent.gateway_accessible_to?(Current.user)
96
+
97
+ @workspace = Collavre::AgentWorkspace.resolve!(agent: @agent, user: Current.user)
98
+ gateway = @workspace.agent_gateway
99
+ if gateway.shared? && Current.user.id != @agent.created_by_id && !Current.user.system_admin?
100
+ head :forbidden
101
+ end
102
+ rescue ArgumentError => error
103
+ raise unless [ "Agent has no gateway", "Agent gateway is inactive" ].include?(error.message)
104
+
105
+ head :service_unavailable
106
+ end
107
+
108
+ def proxy_client
109
+ @proxy_client ||= Collavre::CliProxy::Client.new(gateway: @workspace.agent_gateway, workspace: @workspace)
110
+ end
111
+
112
+ # "Sync now" starts from registration rather than assuming one happened.
113
+ # A manifest URL otherwise only reaches the proxy as a side effect of a
114
+ # login, so a workspace whose registration never landed — an engine
115
+ # authenticated before provisioning existed, a first sync that failed, a
116
+ # proxy that lost its state — could only be repaired by logging in again.
117
+ #
118
+ # Registration is idempotent: re-registering the same URL is a plain
119
+ # re-sync. Two answers mean the proxy will not take a URL from us at all
120
+ # and a plain sync is the whole of what we can do — an operator who pinned
121
+ # the workspace with PROVISION_MANIFEST_URL (409 manifest_url_locked), and
122
+ # a proxy predating the endpoint, whose router answers 404 for it. The one
123
+ # 404 that is not that is provisioning_disabled, where the plain sync would
124
+ # only repeat the same answer and hide which of the two it was.
125
+ def sync_provisioning
126
+ proxy_client.provision_register_manifest(manifest_url)
127
+ rescue Collavre::CliProxy::Client::Error => error
128
+ registration_refused = error.code == "manifest_url_locked" ||
129
+ (error.status == 404 && error.code != "provisioning_disabled")
130
+ raise unless registration_refused
131
+
132
+ proxy_client.provision_sync
133
+ end
134
+
135
+ def manifest_url
136
+ "#{request.base_url}#{agent_provision_manifest_path(
137
+ agent_id: @agent.id,
138
+ token: @workspace.manifest_token
139
+ )}"
140
+ end
141
+
142
+ def render_proxy_error(error)
143
+ status = error.status.to_i.between?(400, 599) ? error.status : :bad_gateway
144
+ render json: { error: { message: error.message, code: error.code } }, status: status
145
+ end
146
+
147
+ # A custom provider URL is supplied by an agent user but fetched by the
148
+ # proxy with its credentials. Check it here as well as at the proxy boundary
149
+ # so a non-admin user cannot use the login form as an internal-network probe.
150
+ def validate_custom_gateway_base_url!(base_url)
151
+ return if Current.user.system_admin?
152
+
153
+ policy = Collavre::CliProxy::EndpointPolicy.new
154
+ return policy.resolve!(base_url) if policy.safe_literal?(base_url)
155
+
156
+ raise Collavre::CliProxy::EndpointPolicy::UnsafeEndpoint
157
+ rescue Collavre::CliProxy::EndpointPolicy::UnsafeEndpoint
158
+ raise Collavre::CliProxy::Client::Error.new(
159
+ t("collavre.agent_connections.errors.insecure_base_url"),
160
+ status: 422,
161
+ code: "insecure_base_url"
162
+ )
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ class AgentGatewaysController < ApplicationController
5
+ before_action :set_gateway, only: %i[edit update destroy check]
6
+
7
+ def index
8
+ @agent_gateways = Current.user.owned_agent_gateways.order(:name)
9
+ end
10
+
11
+ def new
12
+ @agent_gateway = Current.user.owned_agent_gateways.new
13
+ end
14
+
15
+ def create
16
+ @agent_gateway = Current.user.owned_agent_gateways.new(gateway_params)
17
+ if @agent_gateway.save
18
+ redirect_to agent_gateways_path, notice: t("collavre.agent_gateways.created")
19
+ else
20
+ render :new, status: :unprocessable_entity
21
+ end
22
+ end
23
+
24
+ def edit
25
+ @has_stored_completion_key = @agent_gateway.completion_key.present?
26
+ end
27
+
28
+ def update
29
+ attributes = gateway_params
30
+ clear_completion_key = ActiveModel::Type::Boolean.new.cast(attributes.delete(:clear_completion_key))
31
+ @has_stored_completion_key = @agent_gateway.completion_key.present?
32
+ @clear_completion_key = clear_completion_key
33
+
34
+ if clear_completion_key
35
+ attributes[:completion_key] = nil
36
+ elsif attributes[:completion_key].blank?
37
+ attributes.delete(:completion_key)
38
+ end
39
+
40
+ %i[admin_key identity_secret].each do |key|
41
+ attributes.delete(key) if attributes[key].blank?
42
+ end
43
+
44
+ if @agent_gateway.update(attributes)
45
+ redirect_to agent_gateways_path, notice: t("collavre.agent_gateways.updated")
46
+ else
47
+ render :edit, status: :unprocessable_entity
48
+ end
49
+ end
50
+
51
+ def destroy
52
+ if @agent_gateway.destroy
53
+ redirect_to agent_gateways_path, notice: t("collavre.agent_gateways.destroyed")
54
+ else
55
+ redirect_to agent_gateways_path, alert: t("collavre.agent_gateways.in_use")
56
+ end
57
+ end
58
+
59
+ def check
60
+ if @agent_gateway.completion_key.present?
61
+ data = connection_check_client(user_key: @agent_gateway.completion_key).engines
62
+ render_connection_check(data, identity: "completion_key")
63
+ else
64
+ check_with_workspace_or_admin
65
+ end
66
+ rescue Collavre::CliProxy::Client::Error => e
67
+ return retry_connection_check_with_workspace if e.code == "user_identity_required"
68
+
69
+ render json: { ok: false, error: e.message, code: e.code }, status: :bad_gateway
70
+ end
71
+
72
+ private
73
+
74
+ def set_gateway
75
+ @agent_gateway = Current.user.owned_agent_gateways.find(params[:id])
76
+ end
77
+
78
+ def gateway_params
79
+ params.require(:agent_gateway).permit(
80
+ :name, :base_url, :admin_key, :completion_key, :identity_secret,
81
+ :tenant_id, :workspace_mode, :active, :clear_completion_key
82
+ )
83
+ end
84
+
85
+ def retry_connection_check_with_workspace
86
+ workspace = connection_check_workspace
87
+ return render_identity_unverified unless workspace
88
+
89
+ data = connection_check_client(workspace: workspace).engines
90
+ render_connection_check(data, identity: "workspace")
91
+ rescue Collavre::CliProxy::Client::Error => e
92
+ render json: { ok: false, error: e.message, code: e.code }, status: :bad_gateway
93
+ end
94
+
95
+ def check_with_workspace_or_admin
96
+ workspace = connection_check_workspace
97
+ return retry_connection_check_with_workspace if workspace
98
+
99
+ data = connection_check_client.engines
100
+ render_identity_unverified(data)
101
+ end
102
+
103
+ def connection_check_workspace
104
+ scope = @agent_gateway.agent_workspaces.order(:id)
105
+ return scope.find_by(user: Current.user) if @agent_gateway.per_user?
106
+
107
+ scope.joins(:agent).find_by(user_id: nil, users: { created_by_id: Current.user.id })
108
+ end
109
+
110
+ def connection_check_client(user_key: nil, workspace: nil)
111
+ Collavre::CliProxy::Client.new(gateway: @agent_gateway, user_key: user_key, workspace: workspace)
112
+ end
113
+
114
+ def render_connection_check(data, identity:)
115
+ render json: {
116
+ ok: true,
117
+ identity_verified: true,
118
+ identity: identity,
119
+ engines: Array(data["data"]).pluck("engine")
120
+ }
121
+ end
122
+
123
+ def render_identity_unverified(data = {})
124
+ render json: {
125
+ ok: true,
126
+ identity_verified: false,
127
+ engines: Array(data["data"]).pluck("engine"),
128
+ warning: t("collavre.agent_gateways.identity_unverified")
129
+ }
130
+ end
131
+ end
132
+ end