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,5 +1,6 @@
1
1
  module Collavre
2
2
  class CommentsController < ApplicationController
3
+ LEGACY_TOPIC_WATERMARK_KEY = "_legacy"
3
4
  include Collavre::Comments::CommentScoping
4
5
  include Collavre::Comments::ApprovalActions
5
6
  include Collavre::Comments::Conversion
@@ -25,134 +26,7 @@ module Collavre
25
26
  end
26
27
 
27
28
  def index
28
- limit = 20
29
-
30
- visible_scope = @creative.comments.visible_to(Current.user)
31
- scope = visible_scope.with_attached_images.includes(:task, :topic, :comment_reactions, :comment_versions, :snapshot_as_result)
32
-
33
- if params[:search].present?
34
- words = params[:search].to_s.strip.downcase.split(/\s+/)
35
- .first(Creatives::Filters::SearchFilter::MAX_SEARCH_WORDS)
36
- words.each do |word|
37
- sanitized = "%#{ActiveRecord::Base.sanitize_sql_like(word)}%"
38
- scope = scope.where("LOWER(comments.content) LIKE ?", sanitized)
39
- end
40
- end
41
-
42
- # Filter by topic
43
- # Logic:
44
- # 1. Prefer params[:topic_id] if explicit.
45
- # 2. If deep linking (around_comment_id), infer from target comment if valid.
46
- # 3. Default to nil (Main).
47
-
48
- effective_topic_id = params[:topic_id].presence
49
-
50
- if params[:around_comment_id].present?
51
- target_id = params[:around_comment_id].to_i
52
- # Ensure target is visible and belongs to this creative
53
- target_comment = visible_scope.find_by(id: target_id)
54
-
55
- if target_comment
56
- effective_topic_id = target_comment.topic_id
57
- # Inform frontend about the topic switch
58
- response.headers["X-Topic-Id"] = effective_topic_id.to_s
59
- end
60
- end
61
-
62
- # Apply the Topic Filter
63
- if effective_topic_id.present?
64
- scope = scope.where(topic_id: effective_topic_id)
65
- else
66
- # Main view: exclude comments from archived topics
67
- archived_topic_ids = @creative.topics.archived.pluck(:id)
68
- scope = scope.where.not(topic_id: archived_topic_ids) if archived_topic_ids.any?
69
- end
70
-
71
- # Default order: Newest first (created_at DESC)
72
- # This matches the column-reverse layout where the first item in the list is the visual bottom (Newest).
73
- scope = scope.order(created_at: :desc)
74
-
75
-
76
- @comments = if params[:around_comment_id].present?
77
- # Deep linking: Load context around a specific comment
78
- target_id = params[:around_comment_id].to_i
79
-
80
- # Newer messages have HIGHER IDs.
81
- # Older messages have LOWER IDs.
82
-
83
- # Newer bundle (including target): ID >= target_id
84
- newer_bundle = scope.where("comments.id >= ?", target_id).reorder(created_at: :asc).limit(limit / 2 + 1)
85
-
86
- # Older bundle: ID < target_id
87
- older_bundle = scope.where("comments.id < ?", target_id).limit(limit / 2)
88
-
89
- # Combine: [Newer (ASC) ... Target ... Older (DESC)]
90
- # We need final output to be ASC due to restored view logic: [Oldest ... Target ... Newest]
91
- (older_bundle.to_a.reverse + newer_bundle.to_a).uniq
92
- elsif params[:after_id].present? && params[:before_id].present?
93
- # Invalid state, prioritize before (loading older history)
94
- scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
95
- elsif params[:before_id].present?
96
- # Load OLDER messages (lower IDs)
97
- # Visually scrolling UP
98
- scope.where("comments.id < ?", params[:before_id].to_i).limit(limit).to_a.reverse
99
- elsif params[:after_id].present?
100
- # Load NEWER messages (higher IDs)
101
- # Visually scrolling DOWN
102
- # We want the ones immediately *after* the current newest.
103
- # Since default sort is DESC (Newest first), "after" means id > after_id.
104
- # But standard DESC query would give us the VERY Newest.
105
- # We want the ones just above `after_id`.
106
-
107
- # Use reorder(ASC) to get the ones immediately larger than after_id, then reverse back to DESC.
108
- scope.where("comments.id > ?", params[:after_id].to_i).reorder(created_at: :asc).limit(limit)
109
- else
110
- # Initial Load (Latest messages)
111
- scope.limit(limit).to_a.reverse
112
- end
113
-
114
- present_user_ids = CommentPresenceStore.list(@creative.id)
115
-
116
- read_receipts = {}
117
- if @comments.any?
118
- # Fetch all read pointers for this creative that point to comments in the current list
119
- # We only care about pointers that match the IDs of the comments we are displaying?
120
- # Or rather, we want to show the 'line' on the comment that matches the pointer.
121
-
122
- # Optimization: Fetch all pointers for participants of this creative.
123
- # Scoped to the creative.
124
- pointers = CommentReadPointer.where(creative: @creative)
125
- .where.not(last_read_comment_id: nil)
126
- .includes(user: { avatar_attachment: :blob })
127
-
128
- # Fetch all visible IDs for correct read-receipt placement transparency
129
- # Only map read receipts to PUBLIC comments.
130
- # Users who read private comments will appear on the nearest preceding public comment.
131
- public_ids = @creative.comments.public_only.order(id: :asc).pluck(:id)
132
-
133
- pointers.each do |pointer|
134
- effective_id = pointer.effective_comment_id(public_ids)
135
- if effective_id
136
- read_receipts[effective_id] ||= []
137
- read_receipts[effective_id] << pointer.user
138
- end
139
- end
140
- end
141
-
142
- if params[:after_id].present? || params[:before_id].present?
143
- render partial: "collavre/comments/comment",
144
- collection: @comments,
145
- as: :comment,
146
- locals: { read_receipts: read_receipts, present_user_ids: present_user_ids, current_topic_id: effective_topic_id }
147
- else
148
- render partial: "collavre/comments/list", locals: {
149
- comments: @comments,
150
- creative: @creative,
151
- read_receipts: read_receipts,
152
- present_user_ids: present_user_ids,
153
- current_topic_id: effective_topic_id
154
- }
155
- end
29
+ Collavre::Comments::ListResponse.new(controller: self, creative: @creative).render
156
30
  end
157
31
 
158
32
  def create
@@ -173,7 +47,9 @@ module Collavre
173
47
 
174
48
  @comment.user = Current.user
175
49
  @comment.images.attach(image_attachments) if image_attachments.present?
176
- response = ::Comments::CommandProcessor.new(comment: @comment, user: Current.user).call
50
+ response = unless inbox_system_comment?
51
+ ::Comments::CommandProcessor.new(comment: @comment, user: Current.user).call
52
+ end
177
53
  if response.present?
178
54
  @comment.content = "#{@comment.content}\n\n#{response}"
179
55
  @comment.skip_dispatch = true
@@ -199,7 +75,7 @@ module Collavre
199
75
  safe_params = comment_params.except(:quoted_comment_id, :quoted_text)
200
76
  validate_topic_id!(safe_params[:topic_id]) or return
201
77
 
202
- if @comment.update(safe_params)
78
+ if update_comment(safe_params)
203
79
  @comment = Comment.with_attached_images.includes(:comment_reactions, :comment_versions, :selected_version).find(@comment.id)
204
80
  render partial: "collavre/comments/comment", locals: { comment: @comment, current_topic_id: current_topic_context }
205
81
  else
@@ -225,7 +101,7 @@ module Collavre
225
101
  if (is_admin || is_creative_owner) && !is_owner && @comment.user.present? && !@comment.user.ai_user?
226
102
  inbox_creative = Creative.inbox_for(@comment.user)
227
103
  creative_path = Collavre::Engine.routes.url_helpers.creative_path(@creative, open_comments: true)
228
- creative_link = "[#{@creative.creative_snippet}](#{creative_path})"
104
+ creative_link = "[#{@creative.creative_snippet_markdown}](#{creative_path})"
229
105
  msg = I18n.t(
230
106
  "inbox.comment_deleted_by_admin",
231
107
  admin_name: Current.user.name,
@@ -352,10 +228,48 @@ module Collavre
352
228
  params[:topic_id].presence || params.dig(:comment, :topic_id).presence
353
229
  end
354
230
 
231
+ def inbox_system_comment?
232
+ @creative.inbox? && @comment.topic&.name == Creative::SYSTEM_TOPIC_NAME
233
+ end
234
+
355
235
  def validate_topic_id!(topic_id)
356
236
  return true if topic_id.blank? || @creative.topics.where(id: topic_id).exists?
357
237
  render json: { error: I18n.t("collavre.comments.invalid_topic") }, status: :unprocessable_entity
358
238
  false
359
239
  end
240
+
241
+ def update_comment(attributes)
242
+ return update_comment_under_topic_lock(attributes) unless attributes.key?(:topic_id)
243
+
244
+ attributes = attributes.dup
245
+ topic_id = attributes.delete(:topic_id)
246
+ Comment.transaction do
247
+ CommentMoveService.new(creative: @creative, user: Current.user).call(
248
+ comment_ids: [ @comment.id ], target_topic_id: topic_id
249
+ )
250
+ updated = @comment.reload.update(attributes)
251
+ raise ActiveRecord::Rollback unless updated
252
+
253
+ updated
254
+ end
255
+ rescue CommentMoveService::MoveError => e
256
+ @comment.errors.add(:topic, e.message)
257
+ false
258
+ end
259
+
260
+ def update_comment_under_topic_lock(attributes)
261
+ source_topic_id = @comment.topic_id
262
+ matching_context = false
263
+ updated = false
264
+ Comments::TopicMutation.call(source_topic_id, @creative.id) do
265
+ @comment.lock!
266
+ matching_context = @comment.creative_id == @creative.id && @comment.topic_id == source_topic_id
267
+ updated = @comment.update(attributes) if matching_context
268
+ end
269
+ return updated if matching_context
270
+
271
+ @comment.errors.add(:topic, I18n.t("collavre.comments.move_not_allowed"))
272
+ false
273
+ end
360
274
  end
361
275
  end
@@ -5,18 +5,24 @@ module Collavre
5
5
 
6
6
  def request_permission
7
7
  creative = @creative.effective_origin
8
- if creative.user == Current.user || creative.has_permission?(Current.user, :read)
8
+ if creative.user == Current.user || creative.has_permission?(Current.user, :write)
9
9
  return head :unprocessable_entity
10
10
  end
11
11
 
12
- short_title = helpers.strip_tags(creative.effective_origin.description).truncate(10)
12
+ # A requester who already has some access (read or feedback) is
13
+ # specifically asking for a write upgrade — distinguish that from a
14
+ # from-zero access request so the owner knows which one it is.
15
+ already_has_read_access = creative.has_permission?(Current.user, :read)
16
+
17
+ short_title = Collavre::HtmlText.markdown_label(creative.effective_origin.description, 10)
13
18
  creative_path = Collavre::Engine.routes.url_helpers.creative_path(creative, open_comments: true)
14
19
  creative_link = "[#{short_title}](#{creative_path})"
15
20
 
16
21
  inbox_creative = Creative.inbox_for(creative.user)
17
22
  system_topic = inbox_creative.system_topic(fallback_user: creative.user)
23
+ i18n_key = already_has_read_access ? "inbox.write_permission_requested" : "inbox.permission_requested"
18
24
  msg = I18n.t(
19
- "inbox.permission_requested",
25
+ i18n_key,
20
26
  user: Current.user.display_name,
21
27
  short_title: creative_link,
22
28
  locale: creative.user.locale || "en"
@@ -1,5 +1,7 @@
1
1
  module Collavre
2
2
  class CreativesController < ApplicationController
3
+ WORKSPACE_TREE_EXPANSION_LIMIT = 100
4
+
3
5
  include Collavre::Concerns::SlideViewable
4
6
  include Collavre::Concerns::Exportable
5
7
  include Collavre::Concerns::TreeManageable
@@ -17,20 +19,40 @@ module Collavre
17
19
  # tracked separately and intentionally deferred.
18
20
  allow_unauthenticated_access only: %i[ index children export_markdown show slide_view ]
19
21
  before_action :enforce_creatives_login_policy, only: %i[ index children export_markdown show slide_view ]
20
- before_action :set_creative, only: %i[ show edit update destroy parent_suggestions slide_view request_permission unconvert contexts update_contexts update_metadata archive unarchive trigger_action ]
22
+ before_action :set_creative, only: %i[ show edit update destroy slide_view request_permission unconvert contexts update_contexts update_metadata archive unarchive trigger_action remember_last_visited ]
21
23
  before_action :require_creative_write!, only: %i[archive unarchive]
22
24
 
23
25
  def index
24
26
  respond_to do |format|
25
27
  format.html do
28
+ visit_received_at = Time.current
26
29
  # HTML only needs parent_creative for nav/title - skip expensive filtered queries
27
30
  # Must check permission to avoid leaking metadata (og:title, etc.) to unauthorized users
28
31
  if params[:id].present?
29
32
  creative = Creative.find_by(id: params[:id])
30
33
  @parent_creative = creative if creative&.has_permission?(Current.user, :read)
34
+ if Current.user
35
+ @last_visited_creative_client_id = last_visited_creative_client_id
36
+ @last_visited_creative_visit_sequence = last_visited_creative_visit_sequence
37
+ unless request.head? || turbo_prefetch_request?
38
+ @last_visited_creative_visit_sequence = remember_last_visited_creative(
39
+ @parent_creative,
40
+ client_id: @last_visited_creative_client_id,
41
+ sequence: @last_visited_creative_visit_sequence,
42
+ received_at: visit_received_at
43
+ )
44
+ end
45
+ @last_visited_creative_token = last_visited_creative_token
46
+ end
31
47
  end
32
48
  @creatives = [] # CSR will fetch via JSON
33
49
  @shared_list = @parent_creative ? @parent_creative.all_shared_users : []
50
+ if Current.user&.creative_workspace_enabled?
51
+ @workspace_path_ids = Creatives::WorkspacePathResolver.new(
52
+ creative: @parent_creative,
53
+ user: Current.user
54
+ ).call
55
+ end
34
56
  end
35
57
  format.json do
36
58
  # Full query only for JSON requests
@@ -55,6 +77,18 @@ module Collavre
55
77
  @allowed_creative_ids = index_result.allowed_creative_ids
56
78
  @progress_map = index_result.progress_map
57
79
 
80
+ if params[:workspace_tree] == "1"
81
+ expires_now
82
+ render json: {
83
+ creatives: Collavre::Creatives::WorkspaceTreeBuilder.new(
84
+ user: Current.user,
85
+ view_context: view_context,
86
+ expanded_ids: workspace_tree_expanded_ids
87
+ ).build(index_result.creatives)
88
+ }
89
+ return
90
+ end
91
+
58
92
  # Set filtered_progress on parent creative if progress_map is available
59
93
  if @parent_creative && @progress_map && @progress_map.key?(@parent_creative.id.to_s)
60
94
  @parent_creative.filtered_progress = @progress_map[@parent_creative.id.to_s]
@@ -232,15 +266,6 @@ module Collavre
232
266
  end
233
267
  end
234
268
 
235
- def parent_suggestions
236
- unless @creative.has_permission?(Current.user, :read)
237
- render json: { error: t("collavre.creatives.errors.no_permission") }, status: :forbidden and return
238
- end
239
-
240
- suggestions = ::GeminiParentRecommender.new.recommend(@creative)
241
- render json: suggestions
242
- end
243
-
244
269
  def edit
245
270
  unless @creative.has_permission?(Current.user, :write)
246
271
  redirect_to @creative, alert: t("collavre.creatives.errors.no_permission") and return
@@ -526,6 +551,27 @@ module Collavre
526
551
  head :ok
527
552
  end
528
553
 
554
+ # Turbo may restore a cached workspace frame from browser history without
555
+ # issuing the HTML request that normally records this value. The client
556
+ # calls this endpoint after confirming the restored frame matches the URL.
557
+ def remember_last_visited
558
+ visit_received_at = Time.current
559
+ return head :forbidden unless @creative.has_permission?(Current.user, :read)
560
+
561
+ visit = restored_visit
562
+ return head :unprocessable_entity unless visit
563
+
564
+ remember_last_visited_creative(@creative, **visit, received_at: visit_received_at)
565
+ head :no_content
566
+ end
567
+
568
+ # Reserve an ordering value before Turbo starts a Creative navigation.
569
+ # Reserving does not change the remembered Creative, so a cancelled request
570
+ # leaves only a harmless gap in the sequence.
571
+ def next_last_visited_sequence
572
+ render json: { sequence: issue_last_visited_creative_sequence }
573
+ end
574
+
529
575
  def destroy
530
576
  parent = @creative.parent
531
577
  unless @creative.has_permission?(Current.user, :admin)
@@ -539,6 +585,84 @@ module Collavre
539
585
  end
540
586
 
541
587
  private
588
+ def remember_last_visited_creative(creative, client_id:, sequence:, received_at:)
589
+ return unless Current.user && creative
590
+
591
+ Current.user.with_lock do
592
+ same_client = Current.user.last_visited_creative_client_id == client_id
593
+ sequence ||= issue_last_visited_creative_sequence_locked
594
+ return sequence if same_client && Current.user.last_visited_creative_visit_sequence.to_i >= sequence
595
+ return sequence if !same_client && Current.user.last_visited_creative_at &&
596
+ Current.user.last_visited_creative_at >= received_at
597
+
598
+ Current.user.update_columns(
599
+ last_visited_creative_id: creative.id,
600
+ # A request can wait on this lock after it reaches Rails. Retain
601
+ # its arrival time so an older request from another browser session
602
+ # cannot overwrite a Creative recorded by a later-arriving visit.
603
+ last_visited_creative_at: received_at,
604
+ last_visited_creative_client_id: client_id,
605
+ last_visited_creative_visit_sequence: sequence
606
+ )
607
+ sequence
608
+ end
609
+ end
610
+
611
+ def issue_last_visited_creative_sequence
612
+ Current.user.with_lock { issue_last_visited_creative_sequence_locked }
613
+ end
614
+
615
+ def issue_last_visited_creative_sequence_locked
616
+ sequence = [
617
+ Current.user.last_visited_creative_issued_sequence.to_i,
618
+ Current.user.last_visited_creative_visit_sequence.to_i
619
+ ].max + 1
620
+ Current.user.update_column(:last_visited_creative_issued_sequence, sequence)
621
+ sequence
622
+ end
623
+
624
+ def last_visited_creative_client_id
625
+ session[:last_visited_creative_client_id] ||= SecureRandom.uuid
626
+ end
627
+
628
+ def last_visited_creative_visit_sequence
629
+ supplied_sequence = request.headers["X-Collavre-Last-Visited-Creative-Sequence"].to_i
630
+ supplied_sequence if supplied_sequence.positive? && visit_token_client_id == last_visited_creative_client_id
631
+ end
632
+
633
+ def last_visited_creative_token
634
+ return unless @parent_creative && @last_visited_creative_client_id && @last_visited_creative_visit_sequence
635
+
636
+ Rails.application.message_verifier(:last_visited_creative).generate({
637
+ "creative_id" => @parent_creative.id,
638
+ "client_id" => @last_visited_creative_client_id,
639
+ "sequence" => @last_visited_creative_visit_sequence
640
+ })
641
+ end
642
+
643
+ def restored_visit
644
+ visit = Rails.application.message_verifier(:last_visited_creative).verified(params[:visit_token])
645
+ sequence = request.headers["X-Collavre-Last-Visited-Creative-Sequence"].to_i
646
+ return unless visit.is_a?(Hash) && visit["creative_id"] == @creative.id &&
647
+ visit["client_id"] == last_visited_creative_client_id
648
+
649
+ { client_id: visit.fetch("client_id"), sequence: sequence.positive? ? sequence : nil }
650
+ rescue ActiveSupport::MessageVerifier::InvalidSignature, KeyError, ArgumentError
651
+ nil
652
+ end
653
+
654
+ def visit_token_client_id
655
+ token = request.headers["X-Collavre-Last-Visited-Creative-Token"]
656
+ visit = Rails.application.message_verifier(:last_visited_creative).verified(token)
657
+ visit["client_id"] if visit.is_a?(Hash)
658
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
659
+ nil
660
+ end
661
+
662
+ def turbo_prefetch_request?
663
+ request.headers["X-Sec-Purpose"] == "prefetch"
664
+ end
665
+
542
666
  def build_tree(collection, params:, expanded_state_map:, level:, select_mode: false, allowed_creative_ids: nil, progress_map: nil)
543
667
  ::Creatives::TreeBuilder.new(
544
668
  user: Current.user,
@@ -577,6 +701,15 @@ module Collavre
577
701
  ).to_h
578
702
  end
579
703
 
704
+ def workspace_tree_expanded_ids
705
+ permitted = params.permit(expand: [])[:expand]
706
+ Array(permitted).filter_map { |id| Integer(id, exception: false) }
707
+ .uniq
708
+ .first(WORKSPACE_TREE_EXPANSION_LIMIT)
709
+ end
710
+
711
+ helper_method :any_filter_active?
712
+
580
713
  def any_filter_active?
581
714
  params[:tags].present? ||
582
715
  params[:min_progress].present? ||
@@ -677,7 +810,7 @@ module Collavre
677
810
  skip_dispatch: true
678
811
  )
679
812
 
680
- scheduled = SystemEvents::Dispatcher.dispatch("comment_created", comment.dispatch_payload)
813
+ scheduled = SystemEvents::Dispatcher.dispatch("comment_created", comment.dispatch_payload, source: "trigger_restart")
681
814
  Rails.logger.info("[TriggerAction] restart: posted trigger comment #{comment.id}, dispatched to #{scheduled&.size || 0} agents")
682
815
  end
683
816
 
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Collavre
6
+ # Stateless, loopback-only native handoff for the desktop shell. It has no
7
+ # browser session or CSRF cookie; authorization is the signed, short-lived
8
+ # grant issued to the authenticated desktop webview.
9
+ class DesktopGatewayRegistrationsController < ActionController::API
10
+ # Native code calls this before it creates Keychain credentials or starts
11
+ # the local proxy. The signed token is the administrator's short-lived
12
+ # setup consent and remains the sole authorization for the sessionless
13
+ # loopback handoff.
14
+ def validate_registration_grant
15
+ return unless require_desktop_mode!
16
+ return unless require_loopback!
17
+
18
+ registration_owner!
19
+ head :no_content
20
+ rescue ActionController::ParameterMissing, ActiveSupport::MessageVerifier::InvalidSignature => e
21
+ render json: { error: e.message }, status: :unprocessable_entity
22
+ end
23
+
24
+ def create
25
+ return unless require_desktop_mode!
26
+ return unless require_loopback!
27
+
28
+ registration_owner = registration_owner!
29
+ # A desktop has one local proxy, not one proxy per administrator. The
30
+ # persisted desktop_managed marker identifies it independently of its
31
+ # user-editable display name and owner during recovery.
32
+ gateway = Collavre::AgentGateway.find_or_initialize_by(desktop_managed: true)
33
+ gateway.owner ||= registration_owner
34
+ gateway.name ||= available_desktop_gateway_name(gateway.owner)
35
+ owner = gateway.owner
36
+ gateway_attributes = {
37
+ base_url: "http://127.0.0.1:#{proxy_port}",
38
+ admin_key: params.require(:admin_key),
39
+ completion_key: params.require(:completion_key),
40
+ identity_secret: params.require(:identity_secret),
41
+ tenant_id: "collavre-desktop",
42
+ workspace_mode: :shared,
43
+ active: true
44
+ }
45
+
46
+ Collavre::AgentGateway.transaction do
47
+ gateway.update_from_desktop_registration!(gateway_attributes)
48
+ detected_adapters.each { |adapter| provision_preset!(owner, gateway, adapter) }
49
+ end
50
+ render json: { gateway_id: gateway.id, adapters: detected_adapters }, status: :created
51
+ rescue ActiveRecord::RecordInvalid => e
52
+ render json: { error: e.record.errors.full_messages.to_sentence }, status: :unprocessable_entity
53
+ rescue ActiveRecord::RecordNotUnique
54
+ # Concurrent first-run handoffs can both observe no marked gateway. Once
55
+ # one creates it, reload that durable identity and apply this handoff.
56
+ raise if @retried_after_gateway_insert
57
+
58
+ @retried_after_gateway_insert = true
59
+ retry
60
+ rescue ActionController::ParameterMissing, ActiveSupport::MessageVerifier::InvalidSignature => e
61
+ render json: { error: e.message }, status: :unprocessable_entity
62
+ end
63
+
64
+ # Native startup checks this before trusting its persisted `registered`
65
+ # flag. It proves that the Rails gateway still exists at this proxy port
66
+ # and still holds the Keychain credentials without exposing them to the
67
+ # webview or requiring a browser session.
68
+ def registered
69
+ return unless require_desktop_mode!
70
+ return unless require_loopback!
71
+
72
+ gateway = Collavre::AgentGateway.find_by(
73
+ desktop_managed: true,
74
+ base_url: "http://127.0.0.1:#{proxy_port}"
75
+ )
76
+ return head :not_found unless gateway && gateway_credentials_match?(gateway)
77
+
78
+ head :no_content
79
+ rescue ActionController::ParameterMissing => e
80
+ render json: { error: e.message }, status: :unprocessable_entity
81
+ end
82
+
83
+ private
84
+
85
+ def proxy_port
86
+ Integer(params.require(:proxy_port).to_s, 10).tap do |port|
87
+ raise ActionController::ParameterMissing, :proxy_port unless (1..65_535).cover?(port)
88
+ end
89
+ rescue ArgumentError
90
+ raise ActionController::ParameterMissing, :proxy_port
91
+ end
92
+
93
+ def detected_adapters
94
+ Array(params[:adapters]).map(&:to_s).intersection(DesktopSetupController::PRESETS.keys)
95
+ end
96
+
97
+ def gateway_credentials_match?(gateway)
98
+ %i[admin_key completion_key identity_secret].all? do |attribute|
99
+ ActiveSupport::SecurityUtils.secure_compare(gateway.public_send(attribute), params.require(attribute))
100
+ end
101
+ end
102
+
103
+ def provision_preset!(owner, gateway, adapter)
104
+ name, email, model = DesktopSetupController::PRESETS.fetch(adapter)
105
+ agent = owner.created_ai_users.find_or_initialize_by(desktop_preset_adapter: adapter)
106
+ if agent.new_record?
107
+ agent.assign_attributes(
108
+ name: name,
109
+ password: SecureRandom.hex(36),
110
+ email: available_desktop_preset_email(email, gateway),
111
+ email_verified_at: Time.current,
112
+ llm_vendor: "cli_proxy",
113
+ llm_model: model,
114
+ agent_gateway: gateway,
115
+ searchable: false,
116
+ tools: []
117
+ )
118
+ agent.save!
119
+ end
120
+ Collavre::Contact.ensure(user: owner, contact_user: agent)
121
+ end
122
+
123
+ def available_desktop_preset_email(email, gateway)
124
+ return email unless Collavre::User.exists?(email: email)
125
+
126
+ local_part, domain = email.split("@", 2)
127
+ base = "#{local_part}+desktop-#{gateway.id}"
128
+ candidate = "#{base}@#{domain}"
129
+ return candidate unless Collavre::User.exists?(email: candidate)
130
+
131
+ suffix = (2..).find do |number|
132
+ !Collavre::User.exists?(email: "#{base}-#{number}@#{domain}")
133
+ end
134
+ "#{base}-#{suffix}@#{domain}"
135
+ end
136
+
137
+ def available_desktop_gateway_name(owner)
138
+ base_name = DesktopSetupController::DESKTOP_GATEWAY_NAME
139
+ return base_name unless owner.owned_agent_gateways.exists?(name: base_name)
140
+
141
+ suffix = (2..).find { |number| !owner.owned_agent_gateways.exists?(name: "#{base_name} (#{number})") }
142
+ "#{base_name} (#{suffix})"
143
+ end
144
+
145
+ def registration_owner!
146
+ payload = Rails.application.message_verifier("desktop-gateway-registration").verify(params.require(:registration_token))
147
+ raise ActiveSupport::MessageVerifier::InvalidSignature if payload.fetch("expires_at").to_i < Time.current.to_i
148
+
149
+ Collavre::User.find(payload.fetch("user_id")).tap do |user|
150
+ raise ActiveSupport::MessageVerifier::InvalidSignature unless user.system_admin?
151
+ end
152
+ end
153
+
154
+ def require_loopback!
155
+ addr = IPAddr.new(request.remote_addr.to_s)
156
+ addr = addr.native if addr.respond_to?(:ipv4_mapped?) && addr.ipv4_mapped?
157
+ return true if addr.loopback?
158
+
159
+ head :forbidden
160
+ false
161
+ rescue IPAddr::InvalidAddressError
162
+ head :forbidden
163
+ false
164
+ end
165
+
166
+ def require_desktop_mode!
167
+ return true if Rails.application.config.x.desktop_proxy_setup == true
168
+
169
+ head :not_found
170
+ false
171
+ end
172
+ end
173
+ end