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
@@ -0,0 +1,23 @@
1
+ import { decodeHtmlEntities } from "../../../../skills/collavre/scripts/html_entities.js";
2
+
3
+ describe("decodeHtmlEntities", () => {
4
+ test("decodes supported named and numeric entities", () => {
5
+ expect(decodeHtmlEntities("A & B <x> "q" 'a' AB"))
6
+ .toBe("A & B <x> \"q\" 'a' AB");
7
+ });
8
+
9
+ test("decodes each entity exactly once", () => {
10
+ expect(decodeHtmlEntities("&amp;lt; &amp;#60; &#38;gt; &#x26;quot;"))
11
+ .toBe("&lt; &#60; &gt; &quot;");
12
+ });
13
+
14
+ test("preserves unsupported and out-of-range entities", () => {
15
+ expect(decodeHtmlEntities("&copy; &#x110000;"))
16
+ .toBe("&copy; &#x110000;");
17
+ });
18
+
19
+ test("preserves empty values", () => {
20
+ expect(decodeHtmlEntities("")).toBe("");
21
+ expect(decodeHtmlEntities(null)).toBeNull();
22
+ });
23
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+
6
+ const fakeTurbo = { StreamActions: {} }
7
+
8
+ jest.unstable_mockModule('@hotwired/turbo-rails', () => ({ Turbo: fakeTurbo }))
9
+ jest.unstable_mockModule('../../creatives/tree_renderer', () => ({
10
+ createRow: jest.fn(),
11
+ applyRowProperties: jest.fn(),
12
+ replaceProgressControl: jest.fn((_html, controlHtml) => controlHtml),
13
+ updateProgressHtml: jest.fn((html, progress) => progress === 1
14
+ ? html.replace('class="progress-toggle-checkbox"', 'class="progress-toggle-checkbox" checked="checked"')
15
+ .replace('data-current-progress="0"', 'data-current-progress="1"')
16
+ .replace('data-new-progress="1"', 'data-new-progress="0"')
17
+ .replace('title="Mark complete"', 'title="Mark incomplete"')
18
+ : html),
19
+ }))
20
+
21
+ await import('../turbo_stream_actions')
22
+ const { createRow } = await import('../../creatives/tree_renderer')
23
+ const { updateProgressForRow } = await import('../turbo_stream_actions')
24
+
25
+ const EMPTY_HTML = '<div data-creatives-empty-state=""><p>No sub-creatives found.</p></div>'
26
+
27
+ // Mirrors creatives/index.html.erb: title row above the tree container with the
28
+ // server-rendered placeholder inside it.
29
+ function renderEmptyTreeForParent(parentId) {
30
+ document.body.innerHTML = `
31
+ <creative-tree-row is-title creative-id="${parentId}"></creative-tree-row>
32
+ <div id="creatives">${EMPTY_HTML}</div>
33
+ `
34
+ createRow.mockImplementation((creative) => {
35
+ const row = document.createElement('creative-tree-row')
36
+ row.setAttribute('creative-id', String(creative.id))
37
+ row.setAttribute('parent-id', String(creative.parent_id))
38
+ return row
39
+ })
40
+ return document.getElementById('creatives')
41
+ }
42
+
43
+ function dispatchCreativeTreeStream(payload) {
44
+ fakeTurbo.StreamActions.refresh_creative_tree.call({
45
+ getAttribute: (name) => name === 'data' ? JSON.stringify(payload) : null,
46
+ })
47
+ }
48
+
49
+ afterEach(() => {
50
+ document.body.innerHTML = ''
51
+ jest.restoreAllMocks()
52
+ })
53
+
54
+ test('remote destroyed streams notify chat even when no tree row is visible', () => {
55
+ const destroyedEvents = []
56
+ const invalidatedEvents = []
57
+ document.addEventListener('creative-destroyed', (event) => destroyedEvents.push(event.detail), { once: true })
58
+ document.addEventListener('workspace-tree:invalidate', (event) => invalidatedEvents.push(event), { once: true })
59
+
60
+ dispatchCreativeTreeStream({
61
+ action: 'destroyed',
62
+ creative: { id: 123, linked_id: 456, parent_id: 42 },
63
+ })
64
+
65
+ expect(destroyedEvents).toEqual([{ creativeIds: ['456', '123'] }])
66
+ expect(invalidatedEvents).toHaveLength(1)
67
+ })
68
+
69
+ test('remote destroyed streams deduplicate identical effective and origin IDs', () => {
70
+ const listener = jest.fn()
71
+ document.addEventListener('creative-destroyed', listener, { once: true })
72
+
73
+ dispatchCreativeTreeStream({
74
+ action: 'destroyed',
75
+ creative: { id: 123 },
76
+ })
77
+
78
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
79
+ detail: { creativeIds: ['123'] },
80
+ }))
81
+ })
82
+
83
+ test('remote created streams clear the empty-state placeholder', () => {
84
+ const container = renderEmptyTreeForParent(42)
85
+
86
+ dispatchCreativeTreeStream({
87
+ action: 'created',
88
+ creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
89
+ })
90
+
91
+ expect(container.querySelector('creative-tree-row[creative-id="7"]')).not.toBeNull()
92
+ expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(true)
93
+ })
94
+
95
+ test('remote destroyed streams restore the empty-state placeholder for the last row', () => {
96
+ const container = renderEmptyTreeForParent(42)
97
+ dispatchCreativeTreeStream({
98
+ action: 'created',
99
+ creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
100
+ })
101
+
102
+ dispatchCreativeTreeStream({
103
+ action: 'destroyed',
104
+ creative: { id: 7, parent_id: 42 },
105
+ })
106
+
107
+ expect(container.querySelector('creative-tree-row')).toBeNull()
108
+ expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(false)
109
+ })
110
+
111
+ test('remote destroyed streams keep the placeholder hidden while rows remain', () => {
112
+ const container = renderEmptyTreeForParent(42)
113
+ dispatchCreativeTreeStream({
114
+ action: 'created',
115
+ creative: { id: 7, parent_id: 42, level: 2, sequence: 0 },
116
+ })
117
+ dispatchCreativeTreeStream({
118
+ action: 'created',
119
+ creative: { id: 8, parent_id: 42, level: 2, sequence: 1 },
120
+ })
121
+
122
+ dispatchCreativeTreeStream({
123
+ action: 'destroyed',
124
+ creative: { id: 7, parent_id: 42 },
125
+ })
126
+
127
+ expect(container.querySelector('creative-tree-row[creative-id="8"]')).not.toBeNull()
128
+ expect(container.querySelector('[data-creatives-empty-state]').hidden).toBe(true)
129
+ })
130
+
131
+ test('remote progress updates keep checkbox controls actionable', () => {
132
+ const row = {
133
+ dataset: {},
134
+ progressHtml: '<span class="progress-toggle-wrap" data-progress-toggle="true" data-current-progress="0" data-new-progress="1" data-mark-complete="Mark complete" data-mark-incomplete="Mark incomplete" title="Mark complete"><input type="checkbox" class="progress-toggle-checkbox"></span>',
135
+ }
136
+
137
+ updateProgressForRow(row, 1, '100%')
138
+
139
+ expect(row.progressHtml).toContain('checked="checked"')
140
+ expect(row.progressHtml).toContain('data-current-progress="1"')
141
+ expect(row.progressHtml).toContain('data-new-progress="0"')
142
+ expect(row.progressHtml).toContain('title="Mark incomplete"')
143
+ })
144
+
145
+ test('remote child creation changes the parent checkbox into its rollup control', () => {
146
+ document.body.innerHTML = '<div id="creatives"><creative-tree-row creative-id="42"></creative-tree-row></div>'
147
+ const parent = document.querySelector('creative-tree-row[creative-id="42"]')
148
+ parent.progressHtml = '<span data-progress-toggle="true"><input class="progress-toggle-checkbox"></span>'
149
+ parent.dataset.progressHtml = parent.progressHtml
150
+
151
+ dispatchCreativeTreeStream({
152
+ action: 'created',
153
+ creative: {
154
+ id: 7,
155
+ parent_id: 42,
156
+ level: 2,
157
+ sequence: 0,
158
+ ancestors: [{ id: 42, progress: 0.5, progress_html: '<span class="creative-progress-incomplete">50%</span>' }],
159
+ },
160
+ })
161
+
162
+ expect(parent.hasChildren).toBe(true)
163
+ expect(parent.progressHtml).toBe('<span class="creative-progress-incomplete">50%</span>')
164
+ })
165
+
166
+ test('remote child deletion restores the parent leaf checkbox control', () => {
167
+ document.body.innerHTML = `
168
+ <div id="creatives">
169
+ <creative-tree-row creative-id="42" has-children></creative-tree-row>
170
+ <div id="creative-children-42"><creative-tree-row creative-id="7"></creative-tree-row></div>
171
+ </div>
172
+ `
173
+ const parent = document.querySelector('creative-tree-row[creative-id="42"]')
174
+ parent.hasChildren = true
175
+ parent.progressHtml = '<span class="creative-progress-incomplete">0%</span>'
176
+ parent.dataset.progressHtml = parent.progressHtml
177
+ const checkbox = '<span data-progress-toggle="true"><input class="progress-toggle-checkbox"></span>'
178
+
179
+ dispatchCreativeTreeStream({
180
+ action: 'destroyed',
181
+ creative: {
182
+ id: 7,
183
+ parent_id: 42,
184
+ ancestors: [{ id: 42, progress: 0, progress_html: checkbox }],
185
+ },
186
+ })
187
+
188
+ expect(parent.hasChildren).toBe(false)
189
+ expect(parent.progressHtml).toBe(checkbox)
190
+ })
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+
7
+ const csrfFetch = jest.fn()
8
+
9
+ jest.unstable_mockModule('../csrf_fetch', () => ({ default: csrfFetch }))
10
+
11
+ const creativesApi = await import('../creatives')
12
+
13
+ describe('creatives API workspace invalidation', () => {
14
+ afterEach(() => {
15
+ csrfFetch.mockReset()
16
+ })
17
+
18
+ test('invalidates the workspace tree after a successful mutation', async () => {
19
+ const listener = jest.fn()
20
+ document.addEventListener('workspace-tree:invalidate', listener)
21
+ csrfFetch.mockResolvedValue({ ok: true })
22
+
23
+ await creativesApi.destroy(42)
24
+
25
+ expect(listener).toHaveBeenCalledTimes(1)
26
+ document.removeEventListener('workspace-tree:invalidate', listener)
27
+ })
28
+
29
+ test('does not invalidate the workspace tree after a failed mutation', async () => {
30
+ const listener = jest.fn()
31
+ document.addEventListener('workspace-tree:invalidate', listener)
32
+ csrfFetch.mockResolvedValue({ ok: false })
33
+
34
+ await creativesApi.archive(42)
35
+
36
+ expect(listener).not.toHaveBeenCalled()
37
+ document.removeEventListener('workspace-tree:invalidate', listener)
38
+ })
39
+
40
+ test('creates a rich-authored Markdown creative from a picker title', async () => {
41
+ const listener = jest.fn()
42
+ document.addEventListener('workspace-tree:invalidate', listener)
43
+ csrfFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 17 }) })
44
+
45
+ await expect(creativesApi.createFromTitle('New page')).resolves.toEqual({ id: 17 })
46
+
47
+ const [, options] = csrfFetch.mock.calls[0]
48
+ expect(csrfFetch.mock.calls[0][0]).toBe('/creatives')
49
+ expect(options.method).toBe('POST')
50
+ expect(options.body.get('creative[markdown_source]')).toBe('New page')
51
+ expect(options.body.get('creative[content_type_input]')).toBe('markdown')
52
+ expect(options.body.get('creative[markdown_editor]')).toBe('rich')
53
+ expect(listener).toHaveBeenCalledTimes(1)
54
+ document.removeEventListener('workspace-tree:invalidate', listener)
55
+ })
56
+
57
+ test('rejects a failed picker creation without invalidating the workspace tree', async () => {
58
+ const listener = jest.fn()
59
+ document.addEventListener('workspace-tree:invalidate', listener)
60
+ csrfFetch.mockResolvedValue({ ok: false, status: 422 })
61
+
62
+ await expect(creativesApi.createFromTitle('Invalid')).rejects.toThrow(
63
+ 'Failed to create creative: 422',
64
+ )
65
+ expect(listener).not.toHaveBeenCalled()
66
+ document.removeEventListener('workspace-tree:invalidate', listener)
67
+ })
68
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { refreshCsrfToken } from '../csrf_fetch'
7
+
8
+ describe('refreshCsrfToken', () => {
9
+ afterEach(() => {
10
+ delete global.fetch
11
+ })
12
+
13
+ test('marks the refresh as a prefetch and passes through cancellation', async () => {
14
+ const signal = new AbortController().signal
15
+ const fetchMock = jest.fn().mockResolvedValue({ headers: new Headers() })
16
+ global.fetch = fetchMock
17
+
18
+ await refreshCsrfToken({ signal })
19
+
20
+ expect(fetchMock).toHaveBeenCalledWith(window.location.href, {
21
+ method: 'HEAD',
22
+ credentials: 'same-origin',
23
+ headers: { 'X-Sec-Purpose': 'prefetch' },
24
+ signal,
25
+ })
26
+ })
27
+ })
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import { jest } from '@jest/globals'
6
+ import { saveLastTopic } from '../topics'
7
+
8
+ // update_last_topic broadcasts to every session of the saving user, and the
9
+ // broadcast echoes back whatever client_id the save carried. That id is the
10
+ // only thing that tells the session which made the save apart from a sibling
11
+ // session that happened to pick the same topic, so it has to reach the server.
12
+ describe('saveLastTopic', () => {
13
+ const bodyOfLastCall = () => JSON.parse(global.fetch.mock.calls.at(-1)[1].body)
14
+
15
+ beforeEach(() => {
16
+ document.head.innerHTML = '<meta name="csrf-token" content="test-csrf">'
17
+ global.fetch = jest.fn().mockImplementation((url, options) => {
18
+ if (options?.method === 'POST') {
19
+ return Promise.resolve({ ok: true, json: async () => ({ last_topic_save_fence: 1 }) })
20
+ }
21
+ return Promise.resolve({ ok: true, json: async () => ({}) })
22
+ })
23
+ })
24
+
25
+ afterEach(() => {
26
+ document.head.innerHTML = ''
27
+ delete global.fetch
28
+ jest.clearAllMocks()
29
+ })
30
+
31
+ test('sends the client id alongside the topic', async () => {
32
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({ success: true, lastTopicRevision: undefined })
33
+
34
+ expect(global.fetch).toHaveBeenCalledWith(
35
+ '/creatives/42/user_creative_preferences/update_last_topic',
36
+ expect.objectContaining({ method: 'PATCH' }),
37
+ )
38
+ expect(bodyOfLastCall()).toEqual({ last_topic_id: '3', client_id: 'save-abc', last_topic_save_fence: 1 })
39
+ })
40
+
41
+ test('forwards an abort signal to the request', async () => {
42
+ const abortController = new AbortController()
43
+
44
+ await saveLastTopic('42', '3', 'save-abc', abortController.signal)
45
+
46
+ expect(global.fetch).toHaveBeenCalledWith(
47
+ '/creatives/42/user_creative_preferences/update_last_topic',
48
+ expect.objectContaining({ signal: abortController.signal }),
49
+ )
50
+ })
51
+
52
+ test('sends null for both when clearing the selection anonymously', async () => {
53
+ await saveLastTopic('42', null)
54
+
55
+ expect(bodyOfLastCall()).toEqual({ last_topic_id: null, client_id: null, last_topic_save_fence: 1 })
56
+ })
57
+
58
+ test('reports a rejected save rather than throwing', async () => {
59
+ global.fetch.mockResolvedValue({ ok: false })
60
+
61
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBe(false)
62
+ })
63
+
64
+ test('reports a stale ordered save as definitively rejected', async () => {
65
+ global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({ last_topic_save_fence: 1 }) })
66
+ global.fetch.mockResolvedValueOnce({
67
+ ok: true,
68
+ json: async () => ({ success: false, stale_last_topic_save: true, last_topic_revision: [5, 2] }),
69
+ })
70
+
71
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({
72
+ success: false,
73
+ staleLastTopicSave: true,
74
+ lastTopicRevision: [5, 2],
75
+ })
76
+ })
77
+
78
+ test('does not PATCH when issuing the save fence is ambiguous', async () => {
79
+ global.fetch.mockResolvedValue({ ok: false, status: 502 })
80
+
81
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
82
+ expect(global.fetch).toHaveBeenCalledTimes(1)
83
+ })
84
+
85
+ test('falls back to the legacy PATCH when an older server lacks the fence endpoint', async () => {
86
+ global.fetch.mockResolvedValueOnce({ ok: false, status: 404 })
87
+ global.fetch.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
88
+
89
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toEqual({ success: true, lastTopicRevision: undefined })
90
+
91
+ expect(global.fetch).toHaveBeenCalledTimes(2)
92
+ // No fence key at all: an older bundle cannot send one either, and the
93
+ // PATCH handler retires outstanding fences for every unfenced save.
94
+ expect(bodyOfLastCall()).toEqual({
95
+ last_topic_id: '3',
96
+ client_id: 'save-abc',
97
+ })
98
+ })
99
+
100
+ test('reports an ambiguous network failure separately from an HTTP rejection', async () => {
101
+ jest.spyOn(console, 'error').mockImplementation(() => {})
102
+ global.fetch.mockRejectedValue(new Error('offline'))
103
+
104
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
105
+ })
106
+
107
+ test.each([ 502, 504 ])('treats gateway status %i as an ambiguous delivery', async (status) => {
108
+ global.fetch.mockResolvedValue({ ok: false, status })
109
+
110
+ await expect(saveLastTopic('42', '3', 'save-abc')).resolves.toBeNull()
111
+ })
112
+ })
@@ -2,14 +2,15 @@ import csrfFetch from './csrf_fetch'
2
2
 
3
3
  const JSON_HEADERS = { Accept: 'application/json' }
4
4
 
5
- export function get(id) {
6
- return csrfFetch(`/creatives/${id}.json`, {
7
- headers: JSON_HEADERS,
8
- }).then((response) => response.json())
5
+ function invalidateWorkspaceTreeOnSuccess(response) {
6
+ if (response.ok && typeof document !== 'undefined') {
7
+ document.dispatchEvent(new CustomEvent('workspace-tree:invalidate'))
8
+ }
9
+ return response
9
10
  }
10
11
 
11
- export function parentSuggestions(id) {
12
- return csrfFetch(`/creatives/${id}/parent_suggestions.json`, {
12
+ export function get(id) {
13
+ return csrfFetch(`/creatives/${id}.json`, {
13
14
  headers: JSON_HEADERS,
14
15
  }).then((response) => response.json())
15
16
  }
@@ -43,12 +44,30 @@ export function search(query, { simple = false } = {}) {
43
44
  }).then((response) => response.json())
44
45
  }
45
46
 
47
+ export function createFromTitle(title) {
48
+ const body = new FormData()
49
+ body.append('creative[description]', title)
50
+ body.append('creative[content_type_input]', 'markdown')
51
+ body.append('creative[markdown_source]', title)
52
+ body.append('creative[markdown_editor]', 'rich')
53
+
54
+ return csrfFetch('/creatives', {
55
+ method: 'POST',
56
+ headers: JSON_HEADERS,
57
+ body,
58
+ }).then((response) => {
59
+ if (!response.ok) throw new Error(`Failed to create creative: ${response.status}`)
60
+ invalidateWorkspaceTreeOnSuccess(response)
61
+ return response.json()
62
+ })
63
+ }
64
+
46
65
  export function save(action, method, form) {
47
66
  return csrfFetch(action, {
48
67
  method,
49
68
  headers: JSON_HEADERS,
50
69
  body: new FormData(form),
51
- })
70
+ }).then(invalidateWorkspaceTreeOnSuccess)
52
71
  }
53
72
 
54
73
  export function linkExisting(parentId, originId) {
@@ -60,33 +79,33 @@ export function linkExisting(parentId, originId) {
60
79
  method: 'POST',
61
80
  headers: JSON_HEADERS,
62
81
  body,
63
- })
82
+ }).then(invalidateWorkspaceTreeOnSuccess)
64
83
  }
65
84
 
66
85
  export function destroy(id, withChildren = false) {
67
86
  const query = withChildren ? '?delete_with_children=true' : ''
68
87
  return csrfFetch(`/creatives/${id}${query}`, {
69
88
  method: 'DELETE',
70
- })
89
+ }).then(invalidateWorkspaceTreeOnSuccess)
71
90
  }
72
91
 
73
92
  export function archive(id) {
74
93
  return csrfFetch(`/creatives/${id}/archive`, {
75
94
  method: 'PATCH',
76
- })
95
+ }).then(invalidateWorkspaceTreeOnSuccess)
77
96
  }
78
97
 
79
98
  export function unarchive(id) {
80
99
  return csrfFetch(`/creatives/${id}/unarchive`, {
81
100
  method: 'PATCH',
82
- })
101
+ }).then(invalidateWorkspaceTreeOnSuccess)
83
102
  }
84
103
 
85
104
  export function unconvert(id) {
86
105
  return csrfFetch(`/creatives/${id}/unconvert`, {
87
106
  method: 'POST',
88
107
  headers: JSON_HEADERS,
89
- })
108
+ }).then(invalidateWorkspaceTreeOnSuccess)
90
109
  }
91
110
 
92
111
  export function updateMetadata(id, data) {
@@ -102,10 +121,10 @@ export function updateMetadata(id, data) {
102
121
 
103
122
  const creativesApi = {
104
123
  get,
105
- parentSuggestions,
106
124
  loadChildren,
107
125
  browse,
108
126
  search,
127
+ createFromTitle,
109
128
  save,
110
129
  linkExisting,
111
130
  destroy,
@@ -20,15 +20,17 @@ export function updateCsrfTokenFromResponse(response) {
20
20
  }
21
21
 
22
22
  /**
23
- * Fetch a fresh CSRF token from the server. Uses a lightweight HEAD
24
- * request to the current page — the response body is discarded but the
25
- * X-CSRF-Token header gives us a valid token.
23
+ * Fetch a fresh CSRF token from the server. Uses a lightweight HEAD request
24
+ * marked as a prefetch so endpoints that record ordinary visits do not treat
25
+ * the refresh as navigation.
26
26
  */
27
- export async function refreshCsrfToken() {
27
+ export async function refreshCsrfToken({ signal } = {}) {
28
28
  try {
29
29
  const response = await fetch(window.location.href, {
30
30
  method: 'HEAD',
31
31
  credentials: 'same-origin',
32
+ headers: { 'X-Sec-Purpose': 'prefetch' },
33
+ signal,
32
34
  })
33
35
  updateCsrfTokenFromResponse(response)
34
36
  return readCsrfToken()
@@ -30,22 +30,80 @@ export async function fetchNextTopicName(creativeId) {
30
30
  * Save the last selected topic for a creative (server-side persistence).
31
31
  * @param {string|number} creativeId
32
32
  * @param {string|number|null} topicId
33
- * @returns {Promise<boolean>}
33
+ * @param {string} [clientId] - echoed back on the broadcast, so the session
34
+ * that made this save can recognise it and not treat it as someone else's
35
+ * @param {AbortSignal} [signal] - cancels a request that can no longer keep
36
+ * later selections waiting
37
+ * @returns {Promise<{success: true, lastTopicRevision: unknown}|boolean|null>} `null` means the request outcome is unknown
38
+ * (for example, a network failure after the server received the request).
34
39
  */
35
- export async function saveLastTopic(creativeId, topicId) {
40
+ export async function saveLastTopic(creativeId, topicId, clientId, signal) {
36
41
  try {
42
+ const saveFence = await issueLastTopicSaveFence(creativeId, signal)
43
+ if (saveFence !== undefined && !Number.isSafeInteger(saveFence)) return saveFence
44
+
45
+ const body = { last_topic_id: topicId || null, client_id: clientId || null }
46
+ // An undefined fence means this client fell back through an older
47
+ // instance. The PATCH handler retires outstanding fences for every
48
+ // unfenced save, so the fallback needs no marker of its own — an older
49
+ // bundle cannot send one either, and it is the case that has to be
50
+ // ordered correctly.
51
+ if (saveFence !== undefined) body.last_topic_save_fence = saveFence
52
+
37
53
  const response = await fetch(`/creatives/${creativeId}/user_creative_preferences/update_last_topic`, {
38
54
  method: 'PATCH',
39
55
  headers: {
40
56
  'Content-Type': 'application/json',
41
57
  'X-CSRF-Token': csrfToken()
42
58
  },
43
- body: JSON.stringify({ last_topic_id: topicId || null })
59
+ body: JSON.stringify(body),
60
+ signal
44
61
  })
45
- return response.ok
62
+ // A 5xx response can have been generated by a gateway after Rails
63
+ // committed and broadcast the update, so it cannot prove the save did
64
+ // not land. Only an application-level rejection is definitive.
65
+ if (response.ok) {
66
+ let data = {}
67
+ try {
68
+ data = await response.json()
69
+ } catch (_) {
70
+ // Older servers returned an empty success response. Keep accepting it
71
+ // while newer servers provide the revision that orders delayed echoes.
72
+ }
73
+ if (data.success === false) {
74
+ return data.stale_last_topic_save === true
75
+ ? { success: false, staleLastTopicSave: true, lastTopicRevision: data.last_topic_revision }
76
+ : false
77
+ }
78
+ return { success: true, lastTopicRevision: data.last_topic_revision }
79
+ }
80
+ return response.status >= 500 ? null : false
46
81
  } catch (e) {
47
82
  console.error('Error saving last topic', e)
48
- return false
83
+ return null
84
+ }
85
+ }
86
+
87
+ async function issueLastTopicSaveFence(creativeId, signal) {
88
+ try {
89
+ const response = await fetch(`/creatives/${creativeId}/user_creative_preferences/update_last_topic`, {
90
+ method: 'POST',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ 'X-CSRF-Token': csrfToken()
94
+ },
95
+ signal
96
+ })
97
+ // During a rolling deploy, an older instance does not have the POST
98
+ // fence endpoint yet. Its PATCH endpoint remains compatible with the
99
+ // pre-fence request body, so save through that endpoint instead.
100
+ if (response.status === 404) return undefined
101
+ if (!response.ok) return response.status >= 500 ? null : false
102
+ const data = await response.json()
103
+ return Number.isSafeInteger(data.last_topic_save_fence) ? data.last_topic_save_fence : null
104
+ } catch (e) {
105
+ console.error('Error issuing last topic save fence', e)
106
+ return null
49
107
  }
50
108
  }
51
109