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
@@ -1,60 +1,244 @@
1
1
  module Collavre
2
2
  class UserCreativePreferencesController < ApplicationController
3
+ MAX_LAST_TOPIC_SAVE_SESSIONS = 32
4
+ # A concurrent collapse can delete the row between the lookup and the lock.
5
+ # Retrying twice covers that race; beyond it the row is not coming back.
6
+ MAX_PREFERENCE_LOCK_ATTEMPTS = 3
7
+
3
8
  def toggle
4
9
  creative_id = params[:creative_id]
5
10
  node_id = params[:node_id].to_s
6
11
  expanded = ActiveModel::Type::Boolean.new.cast(params[:expanded])
7
12
 
8
- record = UserCreativePreference.find_or_initialize_by(creative_id: creative_id, user_id: Current.user.id)
9
- state = record.expanded_status || {}
13
+ with_preference(creative_id) do |record|
14
+ state = record.expanded_status || {}
10
15
 
11
- if expanded
12
- state[node_id] = true
13
- else
14
- state.delete(node_id)
15
- end
16
+ if expanded
17
+ state[node_id] = true
18
+ else
19
+ state.delete(node_id)
20
+ end
16
21
 
17
- record.expanded_status = state
18
- if state.empty? && record.last_topic_id.nil?
19
- record.destroy if record.persisted?
20
- else
21
- record.save!
22
+ record.expanded_status = state
23
+ if empty_preference?(record, state)
24
+ record.destroy!
25
+ else
26
+ record.save!
27
+ end
22
28
  end
23
29
 
24
30
  render json: { success: true }
25
31
  end
26
32
 
27
33
  def update_last_topic
34
+ return issue_last_topic_save_fence if request.post?
35
+
28
36
  creative = Creative.find(params[:creative_id]).effective_origin
29
37
 
30
- unless creative.has_permission?(Current.user, :read) || creative.user == Current.user
31
- render json: { error: I18n.t("collavre.user_creative_preferences.no_permission") }, status: :forbidden and return
38
+ return render_forbidden unless readable?(creative)
39
+
40
+ result = persist_last_topic_selection(creative)
41
+ return render_invalid_topic unless result
42
+
43
+ record, saved = result
44
+ broadcast_last_topic(creative, record) if saved
45
+
46
+ render json: {
47
+ success: saved,
48
+ stale_last_topic_save: !saved,
49
+ last_topic_revision: [ record.id, record.last_topic_revision ]
50
+ }
51
+ end
52
+
53
+ # Issue this before sending the PATCH so an older request that is still
54
+ # running can never commit after a newer selection. The two counters are
55
+ # deliberately global to this user/creative preference, rather than one
56
+ # per controller session, so their storage stays bounded.
57
+ def issue_last_topic_save_fence
58
+ creative = Creative.find(params[:creative_id]).effective_origin
59
+
60
+ return render_forbidden unless readable?(creative)
61
+
62
+ fence = with_preference(creative.id) do |record|
63
+ record.last_topic_save_fence_issued = [
64
+ record.last_topic_save_fence_issued.to_i,
65
+ record.last_topic_save_fence_applied.to_i
66
+ ].max + 1
67
+ record.save!
68
+ record.last_topic_save_fence_issued
69
+ end
70
+
71
+ render json: { last_topic_save_fence: fence }
72
+ end
73
+
74
+ private
75
+
76
+ def readable?(creative)
77
+ creative.has_permission?(Current.user, :read) || creative.user == Current.user
78
+ end
79
+
80
+ def empty_preference?(record, state)
81
+ state.empty? && record.last_topic_id.nil? && record.last_topic_revision.to_i.zero? &&
82
+ record.last_topic_save_fence_issued.to_i.zero? && record.last_topic_save_fence_applied.to_i.zero?
83
+ end
84
+
85
+ def persist_last_topic_selection(creative)
86
+ topic_id = params[:last_topic_id].presence
87
+ return persist_last_topic(creative) unless topic_id
88
+
89
+ Topic.transaction do
90
+ topic = Topic.lock.find_by(id: topic_id)
91
+ next unless topic&.creative_id == creative.id
92
+
93
+ persist_last_topic(creative)
32
94
  end
95
+ end
96
+
97
+ def persist_last_topic(creative)
98
+ with_preference(creative.id) do |record|
99
+ if stale_last_topic_save?(record)
100
+ next [ record, false ]
101
+ end
33
102
 
34
- # Validate that the topic belongs to this creative
35
- if params[:last_topic_id].present?
36
- unless creative.topics.exists?(id: params[:last_topic_id])
37
- render json: { error: I18n.t("collavre.user_creative_preferences.invalid_topic") }, status: :unprocessable_entity and return
103
+ record.expanded_status ||= {}
104
+ record.last_topic_id = params[:last_topic_id].presence
105
+ record.last_topic_revision = record.last_topic_revision.to_i + 1
106
+ assign_last_topic_save_order(record)
107
+ # Retain a cleared preference after it has participated in last-topic
108
+ # ordering. Its revision lets a reopened client distinguish a newer
109
+ # Main selection from the empty snapshot that preceded an in-flight save.
110
+ if empty_preference?(record, record.expanded_status)
111
+ record.destroy!
112
+ else
113
+ record.save!
38
114
  end
115
+ [ record, true ]
116
+ end
117
+ end
118
+
119
+ # The client id carries a controller session, a monotonically increasing
120
+ # sequence, and an echo nonce. Older clients still send arbitrary ids, so
121
+ # only recognized ordering ids take this fencing path.
122
+ def stale_last_topic_save?(record)
123
+ fence = last_topic_save_fence
124
+ if fence
125
+ return true if fence > record.last_topic_save_fence_issued.to_i
126
+
127
+ return record.last_topic_save_fence_applied.to_i >= fence
39
128
  end
40
129
 
41
- record = UserCreativePreference.find_or_initialize_by(creative_id: creative.id, user_id: Current.user.id)
42
- record.expanded_status ||= {}
43
- record.last_topic_id = params[:last_topic_id].presence
130
+ session_id, sequence = last_topic_save_order
131
+ return false unless session_id.present?
132
+
133
+ last_topic_save_sequences(record).fetch(session_id, 0).to_i >= sequence
134
+ end
44
135
 
45
- if record.expanded_status.empty? && record.last_topic_id.nil?
46
- record.destroy if record.persisted?
136
+ def assign_last_topic_save_order(record)
137
+ fence = last_topic_save_fence
138
+ if fence
139
+ record.last_topic_save_fence_applied = fence
47
140
  else
48
- record.save!
141
+ # Any unfenced PATCH can come from a client running the previous bundle.
142
+ # It supersedes every fence issued before it, so retire them before a
143
+ # delayed fenced save can overwrite it.
144
+ record.last_topic_save_fence_applied = record.last_topic_save_fence_issued.to_i
49
145
  end
50
146
 
51
- # Broadcast only to the current user's sessions (not all creative subscribers)
52
- TopicsChannel.broadcast_to(
53
- "user_#{Current.user.id}_creative_#{creative.id}",
54
- { action: "last_topic_changed", last_topic_id: record.last_topic_id }
55
- )
147
+ # Fenced saves must update this too. A same-session fallback PATCH can
148
+ # outlive an earlier request and must remain ordered behind it.
149
+ session_id, sequence = last_topic_save_order
150
+ return unless session_id.present?
56
151
 
57
- render json: { success: true }
152
+ sequences = last_topic_save_sequences(record)
153
+ # Keep recent sessions in insertion order so a preference cannot retain
154
+ # an unbounded history of tabs that have already gone away.
155
+ sequences.delete(session_id)
156
+ sequences[session_id] = sequence
157
+ sequences.shift while sequences.size > MAX_LAST_TOPIC_SAVE_SESSIONS
158
+
159
+ record.last_topic_save_sequences = sequences
160
+ record.last_topic_save_session_id = session_id
161
+ record.last_topic_save_sequence = sequence
162
+ end
163
+
164
+ # insert_all uses the unique preference key as the first-insert fence.
165
+ # A row lock alone cannot serialize two requests that both see no row.
166
+ def preference_for(creative_id)
167
+ now = Time.current
168
+ attributes = { creative_id: creative_id, user_id: Current.user.id, expanded_status: {}, created_at: now, updated_at: now }
169
+ UserCreativePreference.insert_all([ attributes ], unique_by: :index_user_creative_preferences_on_creative_id_and_user_id)
170
+ UserCreativePreference.find_by!(creative_id: creative_id, user_id: Current.user.id)
171
+ end
172
+
173
+ # A collapse can remove an empty row after it is found but before with_lock
174
+ # reloads it. Reacquire by the unique preference key so every save path
175
+ # shares the same first-insert and deletion-race handling.
176
+ #
177
+ # Only the lookup/lock window is retried, and only a bounded number of
178
+ # times: once the row is locked the block owns the transaction, so a
179
+ # RecordNotFound it raises itself must propagate rather than replay the
180
+ # block, and a row that keeps vanishing must surface instead of spinning.
181
+ def with_preference(creative_id)
182
+ attempts = 0
183
+ locked = false
184
+
185
+ begin
186
+ record = preference_for(creative_id)
187
+ record.with_lock do
188
+ locked = true
189
+ yield record
190
+ end
191
+ rescue ActiveRecord::RecordNotFound
192
+ raise if locked
193
+
194
+ attempts += 1
195
+ raise if attempts >= MAX_PREFERENCE_LOCK_ATTEMPTS
196
+
197
+ retry
198
+ end
199
+ end
200
+
201
+ # Preserve the legacy single-session columns while a rolling deploy may
202
+ # still have records written by the preceding application version.
203
+ def last_topic_save_sequences(record)
204
+ sequences = record.last_topic_save_sequences || {}
205
+ legacy_session_id = record.last_topic_save_session_id
206
+ return sequences unless legacy_session_id.present?
207
+
208
+ legacy_sequence = record.last_topic_save_sequence.to_i
209
+ sequences.merge(legacy_session_id => [ sequences.fetch(legacy_session_id, 0).to_i, legacy_sequence ].max)
210
+ end
211
+
212
+ def last_topic_save_order
213
+ match = params[:client_id].to_s.match(/\A([A-Za-z0-9-]+)\.([1-9]\d*)\.[A-Za-z0-9-]+\z/)
214
+ return [ nil, nil ] unless match
215
+
216
+ [ match[1], match[2].to_i ]
217
+ end
218
+
219
+ def last_topic_save_fence
220
+ value = params[:last_topic_save_fence].to_s
221
+ return unless value.match?(/\A[1-9]\d*\z/)
222
+
223
+ value.to_i
224
+ end
225
+
226
+ def broadcast_last_topic(creative, record)
227
+ payload = {
228
+ action: "last_topic_changed",
229
+ last_topic_id: record.last_topic_id,
230
+ last_topic_revision: [ record.id, record.last_topic_revision ],
231
+ client_id: params[:client_id].presence
232
+ }
233
+ TopicsChannel.broadcast_to("user_#{Current.user.id}_creative_#{creative.id}", payload)
234
+ end
235
+
236
+ def render_forbidden
237
+ render json: { error: I18n.t("collavre.user_creative_preferences.no_permission") }, status: :forbidden
238
+ end
239
+
240
+ def render_invalid_topic
241
+ render json: { error: I18n.t("collavre.user_creative_preferences.invalid_topic") }, status: :unprocessable_entity
58
242
  end
59
243
  end
60
244
  end
@@ -47,9 +47,7 @@ module Collavre
47
47
  visible_scope = @creative.comments.visible_to(Current.user)
48
48
  comments = visible_scope.where(id: comment_ids).to_a
49
49
 
50
- if comments.length != comment_ids.length
51
- render json: { error: I18n.t("collavre.comments.batch_delete_not_found") }, status: :not_found and return
52
- end
50
+ return unless valid_merge_selection?(comments, comment_ids)
53
51
 
54
52
  # All comments must belong to the current user or their AI agents
55
53
  my_ai_agent_ids = Current.user.created_ai_users.pluck(:id)
@@ -88,6 +86,20 @@ module Collavre
88
86
  rescue ActiveRecord::RecordInvalid => e
89
87
  render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
90
88
  end
89
+
90
+ private
91
+
92
+ def valid_merge_selection?(comments, comment_ids)
93
+ if comments.length != comment_ids.length
94
+ render json: { error: I18n.t("collavre.comments.batch_delete_not_found") }, status: :not_found
95
+ false
96
+ elsif comments.map(&:topic_id).uniq.size != 1
97
+ render json: { error: I18n.t("collavre.comments.merge.same_topic_required") }, status: :unprocessable_entity
98
+ false
99
+ else
100
+ true
101
+ end
102
+ end
91
103
  end
92
104
  end
93
105
  end
@@ -36,7 +36,7 @@ module Collavre
36
36
  end
37
37
 
38
38
  def build_convert_system_message(creative)
39
- title = helpers.strip_tags(creative.description).to_s.strip
39
+ title = Collavre::HtmlText.markdown_label(creative.description)
40
40
  title = I18n.t("collavre.comments.convert_system_message_default_title") if title.blank?
41
41
  url = creative_path(creative)
42
42
  I18n.t("collavre.comments.convert_system_message", title: title, url: url)
@@ -10,6 +10,8 @@ module Collavre
10
10
 
11
11
  def new_ai
12
12
  @available_tools = load_available_tools
13
+ @llm_models = Collavre::LlmModel.suggestions
14
+ @agent_gateways = chat_capable_agent_gateways(Current.user)
13
15
 
14
16
  if params[:copy_from].present?
15
17
  source = Collavre::User.find_by(id: params[:copy_from])
@@ -34,6 +36,7 @@ module Collavre
34
36
  llm_model: params[:llm_model],
35
37
  llm_api_key: params[:llm_api_key],
36
38
  gateway_url: params[:gateway_url],
39
+ agent_gateway: selected_agent_gateway,
37
40
  tools: params[:tools] || [],
38
41
  searchable: searchable,
39
42
  email_verified_at: Time.current,
@@ -42,35 +45,122 @@ module Collavre
42
45
  )
43
46
  @user.agent_conf = params[:agent_conf] if @user.respond_to?(:agent_conf=) && params[:agent_conf].present?
44
47
 
45
- if @user.save
48
+ saved = Collavre::User.transaction do
49
+ next false unless @user.save
50
+
51
+ remember_llm_model(@user)
46
52
  Collavre::Contact.ensure(user: Current.user, contact_user: @user)
47
53
  share_ai_agent_to_creative(@user, params[:creative_id])
48
- redirect_to user_path(Current.user, tab: "contacts"), notice: I18n.t("collavre.users.create_ai.success")
54
+ true
55
+ end
56
+
57
+ if saved
58
+ redirect_to current_user_contacts_path, notice: I18n.t("collavre.users.create_ai.success")
49
59
  else
50
60
  flash.now[:alert] = @user.errors.full_messages.to_sentence
51
61
  @available_tools = load_available_tools
62
+ @llm_models = Collavre::LlmModel.suggestions
63
+ @agent_gateways = chat_capable_agent_gateways(Current.user)
52
64
  render :new_ai, status: :unprocessable_entity
53
65
  end
54
66
  end
55
67
 
56
68
  def edit_ai
57
69
  @available_tools = load_available_tools
70
+ @llm_models = Collavre::LlmModel.suggestions
71
+ @agent_gateways = editable_agent_gateways(@user)
72
+ @has_stored_llm_api_key = @user.llm_api_key.present?
73
+ @return_to = safe_return_to(params[:return_to].presence || request.referer)
58
74
  end
59
75
 
60
76
  def update_ai
61
- ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :gateway_url, :searchable, :routing_expression, :agent_conf, tools: [])
77
+ ai_params = params.require(:user).permit(:name, :system_prompt, :llm_vendor, :llm_model, :llm_api_key, :clear_llm_api_key, :gateway_url, :agent_gateway_id, :searchable, :routing_expression, :agent_conf, tools: [])
78
+ effective_vendor = ai_params[:llm_vendor].presence || @user.llm_vendor
79
+ if effective_vendor == "cli_proxy" && ai_params.key?(:agent_gateway_id)
80
+ gateways = gateway_owner_for(@user).owned_agent_gateways
81
+ gateway = if ai_params[:agent_gateway_id].to_s == @user.agent_gateway_id.to_s
82
+ gateways.find_by(id: ai_params[:agent_gateway_id])
83
+ else
84
+ gateways.active.find_by(id: ai_params[:agent_gateway_id])
85
+ end
86
+ ai_params[:agent_gateway_id] = gateway&.id
87
+ elsif ai_params.key?(:llm_vendor)
88
+ ai_params[:agent_gateway_id] = nil
89
+ end
90
+ clear_llm_api_key = ActiveModel::Type::Boolean.new.cast(ai_params.delete(:clear_llm_api_key))
91
+ @has_stored_llm_api_key = @user.llm_api_key.present?
92
+ @clear_llm_api_key = clear_llm_api_key
93
+
94
+ if clear_llm_api_key
95
+ ai_params[:llm_api_key] = nil
96
+ elsif ai_params[:llm_api_key].blank?
97
+ ai_params.delete(:llm_api_key)
98
+ end
99
+
100
+ updated = Collavre::User.transaction do
101
+ next false unless @user.update(ai_params)
102
+
103
+ if @user.saved_change_to_llm_vendor? || @user.saved_change_to_llm_model?
104
+ remember_llm_model(@user)
105
+ end
106
+ true
107
+ end
62
108
 
63
- if @user.update(ai_params)
64
- redirect_to edit_ai_user_path(@user), notice: I18n.t("collavre.users.update_ai.success")
109
+ if updated
110
+ redirect_to update_ai_destination, notice: I18n.t("collavre.users.update_ai.success")
65
111
  else
66
- @available_tools = load_available_tools
67
- flash.now[:alert] = @user.errors.full_messages.to_sentence
68
- render :edit_ai, status: :unprocessable_entity
112
+ render_ai_edit_failure
69
113
  end
70
114
  end
71
115
 
72
116
  private
73
117
 
118
+ def current_user_contacts_path
119
+ user_path(Current.user, tab: "contacts")
120
+ end
121
+
122
+ def render_ai_edit_failure
123
+ @available_tools = load_available_tools
124
+ @llm_models = Collavre::LlmModel.suggestions
125
+ @agent_gateways = editable_agent_gateways(@user)
126
+ @return_to = safe_return_to(params[:return_to])
127
+ flash.now[:alert] = @user.errors.full_messages.to_sentence
128
+ render :edit_ai, status: :unprocessable_entity
129
+ end
130
+
131
+ # Saving should land back on whatever list opened the form (the admin user
132
+ # list, a profile's contacts tab, the org chart), not a fixed page.
133
+ def update_ai_destination
134
+ safe_return_to(params[:return_to]) || current_user_contacts_path
135
+ end
136
+
137
+ def safe_return_to(candidate)
138
+ path = local_path_for(candidate)
139
+ return nil if path.nil? || path.split("?").first == edit_ai_user_path(@user)
140
+
141
+ path
142
+ end
143
+
144
+ def local_path_for(candidate)
145
+ return nil if candidate.blank?
146
+
147
+ uri = URI.parse(candidate)
148
+ return nil if uri.host.present? && uri.host != request.host
149
+ return nil unless uri.path.to_s.start_with?("/") && !uri.path.start_with?("//")
150
+
151
+ [ uri.path, uri.query ].compact_blank.join("?")
152
+ rescue URI::InvalidURIError
153
+ nil
154
+ end
155
+
156
+ def remember_llm_model(user)
157
+ Collavre::LlmModel.remember!(
158
+ vendor: user.llm_vendor,
159
+ name: user.llm_model,
160
+ creator: Current.user
161
+ )
162
+ end
163
+
74
164
  def load_available_tools
75
165
  Collavre::McpService.available_tools(Current.user).map do |tool|
76
166
  {
@@ -81,6 +171,26 @@ module Collavre
81
171
  end
82
172
  end
83
173
 
174
+ def selected_agent_gateway
175
+ return unless params[:llm_vendor] == "cli_proxy"
176
+
177
+ Current.user.owned_agent_gateways.active.find_by(id: params[:agent_gateway_id])
178
+ end
179
+
180
+ def gateway_owner_for(agent)
181
+ agent.creator || Current.user
182
+ end
183
+
184
+ def editable_agent_gateways(agent)
185
+ gateways = gateway_owner_for(agent).owned_agent_gateways
186
+ selected_gateway = gateways.find_by(id: agent.agent_gateway_id)
187
+ (chat_capable_agent_gateways(gateway_owner_for(agent)) + [ selected_gateway ]).compact.uniq.sort_by(&:name)
188
+ end
189
+
190
+ def chat_capable_agent_gateways(owner)
191
+ owner.owned_agent_gateways.active.order(:name).select(&:chat_capable?)
192
+ end
193
+
84
194
  def set_user_for_ai_actions
85
195
  @user = Collavre::User.find(params[:id])
86
196
  end
@@ -58,14 +58,6 @@ module Collavre
58
58
  end
59
59
  end
60
60
 
61
- def typo_correction
62
- @user = Collavre::User.find(params[:id])
63
-
64
- unless @user == Current.user || Current.user.system_admin?
65
- redirect_to user_path(Current.user), alert: I18n.t("collavre.users.destroy.not_authorized")
66
- end
67
- end
68
-
69
61
  def update_password
70
62
  @user = Collavre::User.find(params[:id])
71
63
  if @user.authenticate(params[:user][:current_password])
@@ -98,13 +90,7 @@ module Collavre
98
90
  :calendar_id,
99
91
  :timezone,
100
92
  :locale,
101
- :typo_correction_enabled,
102
- :typo_correction_threshold,
103
- :typo_correction_on_soft_keyboard,
104
- :typo_correction_on_voice,
105
- :typo_correction_on_physical_keyboard,
106
- :typo_correction_in_chat,
107
- :typo_correction_in_editor
93
+ :creative_workspace_enabled
108
94
  ).tap do |p|
109
95
  p[:locale] = normalize_supported_locale(p[:locale]) if p.key?(:locale)
110
96
  end
@@ -11,7 +11,7 @@ module Collavre
11
11
  @user = Collavre::User.new
12
12
  if params[:invite_token].present?
13
13
  @invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
14
- @user.email = @invitation&.email
14
+ @user.email = @invitation.email if @invitation&.email.present?
15
15
  end
16
16
  end
17
17
 
@@ -22,7 +22,7 @@ module Collavre
22
22
  invitation = Collavre::Invitation.find_by_token_for(:invite, params[:invite_token])
23
23
  if invitation
24
24
  @invitation = invitation
25
- @user.email = invitation.email
25
+ @user.email = invitation.email if invitation.email.present?
26
26
  end
27
27
  end
28
28
  if @user.save
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Collavre
4
+ # Raised by AgentLifecycleManager when an agent turn exceeds its wall-clock
5
+ # deadline (SystemSetting.ai_agent_turn_deadline_seconds).
6
+ #
7
+ # Subclasses CancelledError deliberately: AiClient#chat re-raises
8
+ # CancelledError but swallows every other StandardError into a streamed
9
+ # "AI Error" message, and the CancelledError rescue chain (AiAgentService,
10
+ # AiAgentJob) already preserves partial content, keeps the row's terminal
11
+ # status, releases the resource slot and drains the topic queue.
12
+ class TurnDeadlineError < CancelledError
13
+ attr_reader :deadline_seconds
14
+
15
+ def initialize(deadline_seconds)
16
+ @deadline_seconds = deadline_seconds
17
+ super("Turn exceeded the #{deadline_seconds}s deadline")
18
+ end
19
+ end
20
+ end
@@ -6,6 +6,7 @@ module Collavre
6
6
  COLLAVRE_STYLESHEETS = %w[
7
7
  collavre/design_tokens
8
8
  collavre/dark_mode
9
+ collavre/secret_fields
9
10
  collavre/gnb
10
11
  collavre/creatives
11
12
  collavre/actiontext
@@ -14,6 +15,7 @@ module Collavre
14
15
  collavre/org_chart
15
16
  collavre/popup
16
17
  collavre/comments_popup
18
+ collavre/workspace
17
19
  collavre/tables
18
20
  collavre/code_highlight
19
21
  collavre/comment_versions
@@ -43,6 +45,14 @@ module Collavre
43
45
  safe_join(tags, "\n ")
44
46
  end
45
47
 
48
+ def masked_secret_field(form, method, options = {})
49
+ form.text_field(method, masked_secret_field_options(options))
50
+ end
51
+
52
+ def masked_secret_field_tag(name, value = nil, options = {})
53
+ text_field_tag(name, value, masked_secret_field_options(options))
54
+ end
55
+
46
56
  # Returns the body CSS class for the current user's theme.
47
57
  # Custom themes get "light-mode" to disable OS dark mode overrides,
48
58
  # ensuring the custom theme controls all colors regardless of OS setting.
@@ -60,6 +70,25 @@ module Collavre
60
70
  end
61
71
  end
62
72
 
73
+ # The creative index is reachable at both "/" (root route) and
74
+ # "/creatives", so the list filters cannot decide "am I already on the
75
+ # index?" from the pathname alone — they read this instead.
76
+ def creative_index_page?
77
+ controller_path == "collavre/creatives" && action_name == "index"
78
+ end
79
+
80
+ def creative_workspace?
81
+ Current.user&.creative_workspace_enabled? &&
82
+ creative_index_page? &&
83
+ !@auto_fullscreen
84
+ end
85
+
86
+ # Identifies requests made by the packaged Tauri shell. Desktop-only UI
87
+ # fallbacks should use this rather than attempting platform browser checks.
88
+ def desktop?
89
+ request.user_agent.to_s.start_with?("CollavreDesktop/")
90
+ end
91
+
63
92
  # Renders CSS for admin-configured default themes (light/dark mode)
64
93
  # Only applies when the user has no personal theme set
65
94
  def default_theme_styles
@@ -97,6 +126,15 @@ module Collavre
97
126
 
98
127
  private
99
128
 
129
+ def masked_secret_field_options(options)
130
+ options.merge(
131
+ autocomplete: "off",
132
+ autocapitalize: "none",
133
+ spellcheck: false,
134
+ class: class_names("masked-secret-field", options[:class])
135
+ )
136
+ end
137
+
100
138
  CSS_VARIABLE_KEY_PATTERN = /\A--[a-zA-Z0-9_-]+\z/
101
139
  CSS_VARIABLE_VALUE_PATTERN = /\A[^;}{<>"']+\z/
102
140
  ALLOWED_COLOR_SCHEMES = %w[light dark].freeze